@nano-app/backend 0.14.1 → 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> = {
@@ -72,7 +72,7 @@ type ApiResponse<T> = {
72
72
 
73
73
  - A write returns the item as it is after it in `result`: `POST` the created item, `PUT` the updated item, `DELETE` the deleted item. The frontend keeps what an update returns.
74
74
  - Undeclared methods respond `405`.
75
- - An invalid file is discarded with a warning; the other routes keep working.
75
+ - An invalid file stops the startup with its path and what is wrong.
76
76
 
77
77
  ## Configuration
78
78
 
@@ -89,7 +89,9 @@ Every option is optional.
89
89
 
90
90
  `packages` accepts `morgan`, `cors`, `helmet`, `compression`, `express` (`express.json` options) and `urlencoded`. The merge is **per section**: passing one key keeps the others; pass `false` to disable one.
91
91
 
92
- `morgan` logs `:timestamp | :method :path :status` by default: `:path` is the path without its query string, so tokens sent there stay out of the logs. Set `format` with `:url` to log the full URL.
92
+ `compression` is off by default: the proxy in front of the app (an ingress, nginx) usually compresses the answers. Turn it on with `packages: { compression: true }` when nothing does.
93
+
94
+ `morgan` writes a line per request to stdout, synchronously; with the access log of the proxy in front, `packages: { morgan: false }` saves that cost. It logs `:timestamp | :method :path :status` by default: `:path` is the path without its query string, so tokens sent there stay out of the logs. Set `format` with `:url` to log the full URL.
93
95
 
94
96
  ```ts
95
97
  createNanoApp({
@@ -112,61 +114,50 @@ By default the backend reads that header only when the request comes from loopba
112
114
 
113
115
  ## Database
114
116
 
115
- 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.
116
118
 
117
119
  ```bash
118
- 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
119
122
  ```
120
123
 
121
- ```prisma
122
- // src/prisma/schema.prisma
123
- datasource db {
124
- provider = "postgresql"
125
- }
126
-
127
- generator client {
128
- provider = "prisma-client"
129
- output = "./client" // any value: nano-app generates in .nano-app/prisma-clients/app
130
- }
131
-
132
- model User {
133
- id Int @id @default(autoincrement())
134
- email String @unique
135
- }
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
+ });
136
132
  ```
137
133
 
138
134
  ```ts
139
135
  // src/app.ts — DATABASE_URL comes from the environment (or a .env file)
140
- 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 } } });
141
140
  ```
142
141
 
143
142
  ```ts
144
143
  // src/routes/users.route.ts
145
144
  import { createRoute } from '@nano-app/backend';
146
- import type { PrismaClient } from '../../.nano-app/prisma-clients/app/generated/client';
147
145
 
148
- export default createRoute<PrismaClient>({
149
- GET: async (_req, { db }) => ({ result: await db!.user.findMany() }),
146
+ export default createRoute({
147
+ GET: async (_req, { db }) => ({ result: await db.query.users.findMany() }),
150
148
  });
151
149
  ```
152
150
 
153
- | Option | Default | Description |
154
- |---|---|---|
155
- | `mainPlugin.database.enabled` | `false` | Generates and mounts your client at startup. |
156
- | `mainPlugin.database.schemaPath` | `"src/prisma/schema.prisma"` | Your single `.prisma` file; its datasource must be `postgresql`. |
157
-
158
- - Each key falls back to its default, so `{ enabled: true }` is enough with the default layout.
159
- - 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.
160
- - 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.
161
- - 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.
162
- - The `nano_` prefix is reserved for the official plugins (`@nano-app/plugins`): a datasource schema of yours starting with it fails the startup.
163
- - Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
164
- - For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
165
- - 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.
166
157
 
167
158
  ## Better Auth
168
159
 
169
- 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.
170
161
 
171
162
  ```bash
172
163
  pnpm add better-auth
@@ -184,26 +175,26 @@ export default {
184
175
  ```ts
185
176
  // src/app.ts
186
177
  import { createNanoApp } from '@nano-app/backend';
178
+ import * as schema from './db/schema';
187
179
 
188
- createNanoApp({ mainPlugin: { database: { enabled: true } } });
180
+ createNanoApp({ mainPlugin: { database: { schema } } });
189
181
  ```
190
182
 
191
- 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:
192
184
 
193
185
  ```bash
194
- npx auth generate --adapter prisma --dialect postgresql --output src/prisma/schema.prisma
195
- 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
196
188
  ```
197
189
 
198
- - It needs the database: `src/auth.ts` without `mainPlugin.database.enabled` fails at startup, and so does a file without `options`.
199
- - 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.
200
192
  - Its endpoints are mounted with your routes, at its `basePath` (default `/api/auth`), and stay public.
201
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).
202
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).
203
195
  - Handlers and `onBefore` receive the session in `params.session` (`{ user, session }`).
204
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.
205
- - Relate your models to `User` in the same schema, as any Prisma relation. Extra user fields go in its `user.additionalFields`.
206
- - 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`.
207
198
  - Set `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` in the environment, as Better Auth asks.
208
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`.
209
200
  - `createRoute({ public: true, ... })` answers without a session; its handlers still receive `session` when the request has one. An unknown path answers 404.
@@ -290,7 +281,7 @@ The endpoints of one plugin have no guaranteed order: one route's `onInit` must
290
281
 
291
282
  Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
292
283
 
293
- 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).
294
285
 
295
286
  ## Errors
296
287
 
@@ -300,8 +291,8 @@ The framework mounts the 404 and the error handler: an unmatched request becomes
300
291
  throw { status: 409, message: 'Already exists' }; // → 409 { success: false, warning: false, message: "Already exists" }
301
292
  throw 'Field name is required'; // → 400 { success: false, message: "Field name is required" }
302
293
  throw new Error('boom'); // → 500 "Internal server error", logged to stderr
303
- // A Prisma error the request caused answers without its message, which names tables and columns:
304
- // 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"
305
296
  GET /unknown // → 404 { success: false, warning: false, message: "/unknown not found" }
306
297
  ```
307
298
 
@@ -312,7 +303,7 @@ GET /unknown // → 404 { success: false,
312
303
  | `createNanoApp(config?)` | Creates and starts the app. |
313
304
  | `createRoute(route)` | Types a route file. |
314
305
  | `createPlugin(creator)` | Types a plugin factory and its options. |
315
- | `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. |
316
307
 
317
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`).
318
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 & {
@@ -79,12 +79,18 @@ type RouteEntry<Deps = NanoObject<any>> = {
79
79
  } & Handlers<Deps>;
80
80
  /** Generic object */
81
81
  type NanoObject<T = any> = Record<string, T>;
82
- /** Database client configuration. */
82
+ /** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
83
+ declare class NanoError extends Error {
84
+ status?: number;
85
+ /** Error with its message, and the status a handler answers (500 without one) */
86
+ constructor(sMessage: string, iStatus?: number);
87
+ /** Prefix of the framework's messages, shown before the message */
88
+ get name(): string;
89
+ }
90
+ /** Database of a plugin or of the app */
83
91
  type DatabaseConfig = {
84
- /** Whether the database is enabled. Defaults to false */
85
- enabled?: boolean;
86
- /** Prisma schema file (`.prisma`), relative to `cwd` */
87
- 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;
88
94
  };
89
95
  /** When a plugin is mounted, relative to the consumer routes */
90
96
  type PluginStage = 'before' | 'after';
@@ -98,7 +104,7 @@ type PluginConfig<Options = NanoObject> = {
98
104
  basePath?: string;
99
105
  /** Mount point relative to the consumer routes. Defaults to "before" */
100
106
  stage?: PluginStage;
101
- /** Prisma client the plugin needs, mounted before its endpoints */
107
+ /** Drizzle schema of the plugin's database, mounted before its endpoints */
102
108
  database?: DatabaseConfig;
103
109
  /** Plugin options */
104
110
  options?: Options;
@@ -111,13 +117,8 @@ type MainPluginConfig<Options = NanoObject> = {
111
117
  routesDir?: string;
112
118
  /** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
113
119
  basePath?: string;
114
- /** The project's database */
115
- database?: {
116
- /** Whether the database is enabled. Defaults to false */
117
- enabled?: boolean;
118
- /** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
119
- schemaPath?: string;
120
- };
120
+ /** Drizzle schema of the project's database; without it the app has none */
121
+ database?: DatabaseConfig;
121
122
  /** Plugin options */
122
123
  options?: Options;
123
124
  /** Plugin callbacks */
@@ -185,4 +186,4 @@ export declare function createPlugin<Options>(fnCreator: Creator<Options>): Crea
185
186
  export declare function createPluginCallbacks<Deps = NanoObject<any>>(oCallbacks: PluginCallbacks<Deps>): PluginCallbacks<Deps>;
186
187
  /** Whether the session's user has the permissions; always without Better Auth or permissions */
187
188
  declare function isGranted({ auth, session }: Params, oPermissions?: NanoObject<string[]>): Promise<boolean>;
188
- export { type ApiPromise, type ApiResponse, type Callbacks, type DatabaseConfig, type Handler, type Handlers, type MainPluginConfig, type MethodEntry, NanoApp, type NanoAppConfig, type NanoObject, type PackagesConfig, type Params, type PluginCallbacks, type PluginConfig, type PluginStage, type QueryParams, type Request, type RouteEntry, isGranted };
189
+ export { type ApiPromise, type ApiResponse, type Callbacks, type DatabaseConfig, type Handler, type Handlers, type MainPluginConfig, type MethodEntry, NanoApp, type NanoAppConfig, NanoError, type NanoObject, type PackagesConfig, type Params, type PluginCallbacks, type PluginConfig, type PluginStage, type QueryParams, type Request, type RouteEntry, isGranted };
package/build/index.js CHANGED
@@ -1,16 +1,62 @@
1
- import { createRequire } from "node:module";
2
1
  import express from "express";
3
2
  import morgan from "morgan";
4
3
  import cors from "cors";
5
4
  import helmet from "helmet";
6
5
  import compression from "compression";
7
6
  import "dotenv/config";
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
7
  import { glob } from "glob";
10
- import { dirname, isAbsolute, join, resolve } from "node:path";
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";
11
+ // ../core/utils/NanoError.ts
12
+ /** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
13
+ var NanoError = class extends Error {
14
+ status;
15
+ /** Error with its message, and the status a handler answers (500 without one) */
16
+ constructor(sMessage, iStatus) {
17
+ super(sMessage);
18
+ this.status = iStatus;
19
+ }
20
+ /** Prefix of the framework's messages, shown before the message */
21
+ get name() {
22
+ return "[nano-app] [ERROR]";
23
+ }
24
+ };
25
+ // ../core/files/FileReader.ts
26
+ /** Reads and loads files from disk, resolved from `cwd` */
27
+ var FileReader = class {
28
+ /** Loader of the consumer's modules */
29
+ static oJiti = createJiti(import.meta.url);
30
+ /** Loads a module via jiti */
31
+ static async loadModule(sFilePath, oOptions = {}) {
32
+ return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
33
+ }
34
+ /** Resolves the absolute path of a file */
35
+ static getAbsolutePath(sFilePath) {
36
+ const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
37
+ if (!existsSync(sAbsolutePath)) throw new NanoError(`${sFilePath} not found.`);
38
+ return sAbsolutePath;
39
+ }
40
+ };
41
+ // ../core/files/FileLoader.ts
42
+ /** Finds and loads the consumer's files */
43
+ var FileLoader = class {
44
+ /** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
45
+ static async findMany(sExtension, sSourceDir) {
46
+ const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
47
+ const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
48
+ posix: true,
49
+ ignore: "**/*.d.ts"
50
+ });
51
+ const oEntries = {};
52
+ for (const sFilePath of aFilePaths) {
53
+ const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${sExtension}\\.(ts|js)$`), "");
54
+ const sKey = sExtension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
55
+ oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
56
+ }
57
+ return oEntries;
58
+ }
59
+ };
14
60
  // ../core/const.ts
15
61
  /** HTTP methods a route can handle */
16
62
  const HTTP_METHODS = [
@@ -20,10 +66,6 @@ const HTTP_METHODS = [
20
66
  "DELETE",
21
67
  "QUERY"
22
68
  ];
23
- /** Package of the official plugins: only its schemas may declare nano_* */
24
- const OFFICIAL_PLUGINS = "@nano-app/plugins";
25
- /** Error when the database URL is missing at startup */
26
- const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
27
69
  /** Loopback and private networks: the proxies the backend trusts by default */
28
70
  const PRIVATE_NETWORKS = [
29
71
  "127.0.0.0/8",
@@ -33,142 +75,101 @@ const PRIVATE_NETWORKS = [
33
75
  "192.168.0.0/16",
34
76
  "fc00::/7"
35
77
  ];
36
- // ../core/loader/Validator.ts
78
+ // ../core/utils/Paths.ts
79
+ /** Paths of the app's pages */
80
+ var Paths = class {
81
+ /** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
82
+ static matches(sPattern, sPath) {
83
+ if (!sPattern.endsWith("/*")) return sPattern === sPath;
84
+ const sBase = sPattern.slice(0, -2);
85
+ return sPath === sBase || sPath.startsWith(`${sBase}/`);
86
+ }
87
+ };
88
+ // ../core/utils/Validator.ts
37
89
  const STORE_TYPES = ["app", "api"];
38
- /** Checks the consumer's files: invalid ones are warned about and discarded */
90
+ /** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
39
91
  var Validator = class Validator {
40
- static oValidators = {
41
- route: () => this.isRoute,
42
- prisma: () => this.isPrisma,
43
- store: () => this.isStore
44
- };
45
- /** Warns about an invalid file and returns `false`, to discard it */
46
- static warn(sFilePath, sMessage) {
47
- console.warn(`[nano-app] [WARNING] ${sFilePath}: ${sMessage}`);
48
- return false;
92
+ /** Whether a value is a plain object */
93
+ static isObject(oValue) {
94
+ return Object.prototype.toString.call(oValue) === "[object Object]";
95
+ }
96
+ /** Whether a value is a plain object of strings */
97
+ static isStringMap(oValue) {
98
+ return Validator.isObject(oValue) && Object.values(oValue).every((sItem) => typeof sItem === "string");
99
+ }
100
+ /** Whether a value is a path that starts with `/` */
101
+ static isPath(oValue) {
102
+ return typeof oValue === "string" && oValue.startsWith("/");
103
+ }
104
+ /** First environment variable of a list that is not defined */
105
+ static missingEnv(aNames) {
106
+ return aNames.find((sName) => !process.env[sName]);
49
107
  }
50
- /** Checks that the default export is an object */
51
- static isObject(oEntry, sFilePath) {
52
- if (oEntry && typeof oEntry === "object") return true;
53
- return Validator.warn(sFilePath, "the default export is not an object.");
108
+ /** Checks every entry with a guard (`Validator.isRoute`...): the first invalid one throws */
109
+ static validate(oEntries, fnGuard) {
110
+ return Object.fromEntries(Object.entries(oEntries).filter(([sKey, oEntry]) => fnGuard(oEntry, sKey)));
54
111
  }
55
112
  /** Type guard for a `*.route.ts` entry */
56
113
  static isRoute(oEntry, sFilePath) {
57
- if (!Validator.isObject(oEntry, sFilePath)) return false;
58
- if (oEntry.expression && typeof oEntry.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
59
- if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") return Validator.warn(sFilePath, "\"public\" must be a boolean.");
114
+ if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
115
+ if (oEntry.expression && typeof oEntry.expression !== "string") throw new NanoError(`${sFilePath}: "expression" must be a string.`);
116
+ if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") throw new NanoError(`${sFilePath}: "public" must be a boolean.`);
60
117
  for (const sMethod of HTTP_METHODS) {
61
118
  const oMethod = oEntry[sMethod];
62
- if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") return Validator.warn(sFilePath, `"${sMethod}" must be a function or { handler, permissions }.`);
119
+ if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") throw new NanoError(`${sFilePath}: "${sMethod}" must be a function or { handler, permissions }.`);
63
120
  }
64
- if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
121
+ if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) throw new NanoError(`${sFilePath}: defines no HTTP method.`);
65
122
  return true;
66
123
  }
67
124
  /** Type guard for a `*.store.ts` entry */
68
125
  static isStore(oEntry, sFilePath) {
69
- if (!Validator.isObject(oEntry, sFilePath)) return false;
70
- if (!STORE_TYPES.includes(oEntry.type)) return Validator.warn(sFilePath, `its type must be "app" or "api".`);
71
- if (oEntry.data !== void 0 && (!oEntry.data || typeof oEntry.data !== "object")) return Validator.warn(sFilePath, "\"data\" must be an object.");
72
- if (oEntry.headers !== void 0 && (!Validator.isObject(oEntry.headers, sFilePath) || Object.values(oEntry.headers).some((sValue) => typeof sValue !== "string"))) return Validator.warn(sFilePath, "\"headers\" must be an object of strings.");
73
- if (oEntry.auth !== void 0 && typeof oEntry.auth !== "string") return Validator.warn(sFilePath, "\"auth\" must be a string: the base path of Better Auth.");
126
+ if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
127
+ if (!STORE_TYPES.includes(oEntry.type)) throw new NanoError(`${sFilePath}: its type must be "app" or "api".`);
128
+ if (oEntry.data !== void 0 && !Validator.isObject(oEntry.data)) throw new NanoError(`${sFilePath}: "data" must be an object.`);
129
+ if (oEntry.headers !== void 0 && !Validator.isStringMap(oEntry.headers)) throw new NanoError(`${sFilePath}: "headers" must be an object of strings.`);
130
+ if (oEntry.auth !== void 0 && !Validator.isAuth(oEntry.auth)) throw new NanoError(`${sFilePath}: "auth" must be { path, routes }: each route { match, redirect, roles, permissions } with paths starting with /, roles as "admin,user", permissions as { resource: "read,update" }, and a redirect outside its match.`);
74
131
  return true;
75
132
  }
76
- /** Type guard for a `.prisma` entry: the contents of the file, without comments */
77
- static isPrisma(oEntry, sFilePath) {
78
- if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
79
- const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
80
- if (sProvider !== "postgresql") return Validator.warn(sFilePath, `the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
81
- return true;
82
- }
83
- /** Validates a full map of entries and returns only the valid ones */
84
- static validate(oEntries, sExtension) {
85
- const oValid = {};
86
- for (const [sFilePath, oEntry] of Object.entries(oEntries)) {
87
- const oValidated = this.validateUnique(oEntry, sExtension, sFilePath);
88
- if (oValidated !== void 0) oValid[sFilePath] = oValidated;
89
- }
90
- return oValid;
91
- }
92
- /** Validates one entry: returns it when valid, `undefined` otherwise */
93
- static validateUnique(oEntry, sExtension, sFilePath = "") {
94
- return this.oValidators[sExtension]?.()(oEntry, sFilePath) ? oEntry : void 0;
95
- }
96
- };
97
- // ../core/loader/FileReader.ts
98
- /** Reads and loads files from disk, resolved from `cwd` */
99
- var FileReader = class {
100
- /** Loader of the consumer's modules; the Prisma runtime is loaded natively */
101
- static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
102
- /** Loads a module via jiti */
103
- static async loadModule(sFilePath, oOptions = {}) {
104
- return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
105
- }
106
- /** Reads the contents of a file */
107
- static loadFile(sFilePath) {
108
- const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
109
- if (!sContent) throw new Error(`[nano-app] [ERROR] The file ${sFilePath} does not have valid content.`);
110
- return sContent;
111
- }
112
- /** Reads a Prisma schema without its `//` comments */
113
- static loadSchema(sFilePath) {
114
- return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
115
- }
116
- /** Resolves the absolute path of a file */
117
- static getAbsolutePath(sFilePath) {
118
- const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
119
- if (!existsSync(sAbsolutePath)) throw new Error(`[nano-app] [ERROR] ${sFilePath} not found.`);
120
- return sAbsolutePath;
133
+ /** Whether `auth` has the base path of Better Auth and valid routes, none redirecting to itself */
134
+ static isAuth(oAuth) {
135
+ if (!Validator.isObject(oAuth) || typeof oAuth.path !== "string") return false;
136
+ const aRoutes = oAuth.routes ?? [];
137
+ if (!Array.isArray(aRoutes)) return false;
138
+ return aRoutes.every((oRoute) => {
139
+ if (!Validator.isObject(oRoute)) return false;
140
+ const { match, redirect = "/", roles, permissions } = oRoute;
141
+ return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
142
+ });
121
143
  }
122
144
  };
123
- // ../core/utils/Formatter.ts
124
- /** Formats values for logs and messages */
125
- var Formatter = class {
126
- /** Message of anything thrown: its `message` when it has one, else the value itself */
127
- static error(oError) {
128
- if (oError && typeof oError === "object" && "message" in oError) return String(oError.message ?? oError);
129
- return String(oError);
130
- }
131
- /** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
132
- static logDate(oDate = /* @__PURE__ */ new Date()) {
145
+ // ../core/utils/Logger.ts
146
+ /** Messages of the framework on the console */
147
+ var Logger = class Logger {
148
+ /** Message of the framework: `[nano-app] [<level>] <text>` */
149
+ static message(sLevel, sText) {
150
+ return `[nano-app] [${sLevel}] ${sText}`;
151
+ }
152
+ /** `[nano-app] [ERROR] <text>`, with what was thrown */
153
+ static error(sText, oError) {
154
+ console.error(Logger.message("ERROR", sText), oError);
155
+ }
156
+ /** `[nano-app] [WARNING] <text>` */
157
+ static warn(sText) {
158
+ console.warn(Logger.message("WARNING", sText));
159
+ }
160
+ /** `[nano-app] [INFO] <text>` */
161
+ static info(sText) {
162
+ console.log(Logger.message("INFO", sText));
163
+ }
164
+ /** Date of the request log, in UTC: `2026-10-19 12:03:10` */
165
+ static date(oDate = /* @__PURE__ */ new Date()) {
133
166
  return oDate.toISOString().slice(0, 19).replace("T", " ");
134
167
  }
135
168
  };
136
- // ../core/loader/FileLoader.ts
137
- /** Finds, loads and validates the consumer's files */
138
- var FileLoader = class {
139
- /**
140
- * 1. Validates a `.prisma` file.
141
- * 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
142
- */
143
- static async findPrismaClient(sClientPath, sSchemaPath) {
144
- const sContent = FileReader.loadSchema(sSchemaPath);
145
- if (!Validator.validateUnique(sContent, "prisma", sSchemaPath)) throw new Error(`[nano-app] [ERROR] ${sSchemaPath} is not a valid Prisma schema.`);
146
- try {
147
- return (await FileReader.loadModule(sClientPath))?.PrismaClient;
148
- } catch (oError) {
149
- throw new Error(`[nano-app] [ERROR] The Prisma client ${sClientPath} could not be loaded: ${Formatter.error(oError)}`);
150
- }
151
- }
152
- /** Finds `*.route.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
153
- static async findMany({ extension, sourceDir }) {
154
- const sSrcDir = (sourceDir ?? `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
155
- const aFilePaths = await glob(`${sSrcDir}/**/*.${extension}.{ts,js}`, {
156
- posix: true,
157
- ignore: "**/*.d.ts"
158
- });
159
- const oEntries = {};
160
- for (const sFilePath of aFilePaths) {
161
- const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
162
- const sKey = extension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
163
- oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
164
- }
165
- return Validator.validate(oEntries, extension);
166
- }
167
- };
168
169
  // src/auth/BetterAuth.ts
169
170
  /** Files of the app's Better Auth options, relative to `cwd`: source, then built */
170
171
  const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
171
- /** 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 */
172
173
  var BetterAuth = class {
173
174
  static oAuth;
174
175
  /** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
@@ -176,21 +177,21 @@ var BetterAuth = class {
176
177
  const sFilePath = AUTH_FILES.find(existsSync);
177
178
  if (!sFilePath) return void 0;
178
179
  const oFile = await FileReader.loadModule(sFilePath, { default: true });
179
- if (!oFile?.options) throw new Error(`[nano-app] [ERROR] ${sFilePath} must default export { options }: the Better Auth options.`);
180
+ if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
180
181
  return oFile.options;
181
182
  }
182
183
  /** The instance, once the app's plugin created it */
183
184
  static get() {
184
185
  return this.oAuth;
185
186
  }
186
- /** Creates the instance on the app's client, its handler and the session lookup */
187
- 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) {
188
189
  const { betterAuth } = await import("better-auth");
189
- const { prismaAdapter } = await import("better-auth/adapters/prisma");
190
+ const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
190
191
  const { getSchema } = await import("better-auth/db");
191
192
  const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
192
- const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oDb[sModel]);
193
- if (aMissing.length) throw new Error(`[nano-app] [ERROR] 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.`);
194
195
  const oAuth = betterAuth({
195
196
  ...oOptions,
196
197
  advanced: {
@@ -200,15 +201,16 @@ var BetterAuth = class {
200
201
  ...oOptions.advanced?.ipAddress
201
202
  }
202
203
  },
203
- database: prismaAdapter(oDb, { provider: "postgresql" })
204
+ database: drizzleAdapter(oDb, {
205
+ provider: "pg",
206
+ schema: oSchema
207
+ })
204
208
  });
205
209
  this.oAuth = oAuth;
206
- const fnSession = (oReq, oRes, fnNext) => {
207
- oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
208
- oRes.locals.session = oSession ?? void 0;
209
- fnNext();
210
- }).catch(fnNext);
211
- };
210
+ const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
211
+ oRes.locals.session = oSession ?? void 0;
212
+ fnNext();
213
+ });
212
214
  return {
213
215
  oHandler: toNodeHandler(oAuth),
214
216
  fnSession,
@@ -247,33 +249,24 @@ var Endpoint = class {
247
249
  }
248
250
  /** Handler for all methods */
249
251
  async mount(oParams, sPath) {
250
- if (!oParams.auth && Object.values(this.oMethods).some(({ permissions }) => permissions)) console.warn(`[nano-app] [WARNING] ${sPath}: its permissions are not checked without Better Auth. Add src/auth.ts.`);
252
+ if (!oParams.auth && Object.values(this.oMethods).some(({ permissions }) => permissions)) Logger.warn(`${sPath}: its permissions are not checked without Better Auth. Add src/auth.ts.`);
251
253
  await this.oCallbacks.onInit?.(oParams);
252
254
  return this.createHandler(oParams);
253
255
  }
254
256
  /** Express handler: onBefore, then the method of the request */
255
257
  createHandler(oParams) {
256
258
  return (oReq, oRes, fnNext) => {
257
- if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext({
258
- status: 401,
259
- message: "Not authenticated"
260
- });
259
+ if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
261
260
  const sMethod = oReq.method.toUpperCase();
262
261
  const sHandled = HTTP_METHODS.find((sHttpMethod) => sHttpMethod === sMethod);
263
262
  const oMethod = sHandled && this.oMethods[sHandled];
264
- if (!oMethod) return fnNext({
265
- status: 405,
266
- message: `Method ${sMethod} not implemented`
267
- });
263
+ if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
268
264
  const oRequestParams = {
269
265
  ...oParams,
270
266
  session: oRes.locals.session
271
267
  };
272
- isGranted(oRequestParams, this.isPublic ? void 0 : oMethod.permissions).then((isAllowed) => {
273
- if (!isAllowed) throw {
274
- status: 403,
275
- message: "Forbidden"
276
- };
268
+ return isGranted(oRequestParams, this.isPublic ? void 0 : oMethod.permissions).then((isAllowed) => {
269
+ if (!isAllowed) throw new NanoError("Forbidden", 403);
277
270
  return this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? {};
278
271
  }).then((onBeforeResult) => oMethod.handler(oReq, {
279
272
  ...oRequestParams,
@@ -285,14 +278,13 @@ var Endpoint = class {
285
278
  ...oBody,
286
279
  success: iStatus < 400
287
280
  });
288
- }).catch(fnNext);
281
+ });
289
282
  };
290
283
  }
291
284
  };
292
285
  // src/routes/Router.ts
293
286
  /** Turns a folder of `*.route.ts` files into Express paths and endpoints */
294
287
  var Router = class {
295
- oRoutes = {};
296
288
  sRoutesDir;
297
289
  sBasePath;
298
290
  constructor(sRoutesDir, sBasePath) {
@@ -301,13 +293,12 @@ var Router = class {
301
293
  }
302
294
  /** Loads the routes and creates one endpoint per file */
303
295
  async mount() {
304
- if (this.sRoutesDir) this.oRoutes = await FileLoader.findMany({
305
- sourceDir: this.sRoutesDir,
306
- extension: "route"
307
- });
308
- return Object.entries(this.oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
296
+ if (!this.sRoutesDir) return [];
297
+ const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
298
+ const oRoutes = Validator.validate(oFiles, Validator.isRoute);
299
+ return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
309
300
  const sPath = this.createPath(sFilePath, expression);
310
- console.log(`[nano-app] [INFO] ${sPath}`);
301
+ Logger.info(sPath);
311
302
  return {
312
303
  path: sPath,
313
304
  endpoint: new Endpoint(methods, {
@@ -323,138 +314,43 @@ var Router = class {
323
314
  return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
324
315
  }
325
316
  };
326
- // src/database/Prisma.ts
327
- /** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
328
- var Prisma = class {
329
- /** Folder of a client and its files, in the project */
330
- static dir(sName) {
331
- return join(process.cwd(), ".nano-app", "prisma-clients", sName);
332
- }
333
- /** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
334
- static generate(sName, sSchemaPath) {
335
- const sDir = this.dir(sName);
336
- const sSource = FileReader.getAbsolutePath(sSchemaPath);
337
- const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
338
- const sSourceFile = join(sDir, "source.json");
339
- const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
340
- if (oCurrent && oCurrent.schemaPath !== sSource) console.warn(`[nano-app] [WARNING] .nano-app/prisma-clients/${sName} has the client of ${oCurrent.schemaPath}: it is replaced by the one of ${sSource}.`);
341
- if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
342
- mkdirSync(sDir, { recursive: true });
343
- const sSchema = readFileSync(sSource, "utf-8");
344
- writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
345
- this.run(sName, [
346
- "generate",
347
- "--schema",
348
- join(sDir, "schema.prisma")
349
- ]);
350
- writeFileSync(sSourceFile, JSON.stringify({
351
- schemaPath: sSource,
352
- hash: sHash
353
- }));
354
- console.log(`[nano-app] [INFO] Prisma client "${sName}" generated`);
355
- }
356
- /** Applies the pending migrations an official plugin ships next to its schema */
357
- static migrate(sName, sSchemaPath, sDbSchema) {
358
- const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
359
- if (!existsSync(sMigrations)) throw new Error(`[nano-app] [ERROR] The plugin "${sName}" ships no migrations: ${sMigrations}`);
360
- if (!process.env.DATABASE_URL) throw new Error(MISSING_DATABASE_URL);
361
- const oUrl = new URL(process.env.DATABASE_URL);
362
- oUrl.searchParams.set("schema", sDbSchema);
363
- const sConfig = join(this.dir(sName), "prisma.config.mjs");
364
- writeFileSync(sConfig, [
365
- "export default {",
366
- ` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
367
- ` migrations: { path: ${JSON.stringify(sMigrations)} },`,
368
- " datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
369
- "};",
370
- ""
371
- ].join("\n"));
372
- this.run(sName, [
373
- "migrate",
374
- "deploy",
375
- "--config",
376
- sConfig
377
- ], { NANO_APP_MIGRATION_URL: oUrl.toString() });
378
- }
379
- /** Runs a Prisma command of the consumer's `prisma` package */
380
- static run(sName, aArgs, oEnv = {}) {
381
- let sCli;
382
- try {
383
- sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
384
- } catch {
385
- throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma. Run \`pnpm add prisma\`.`);
386
- }
387
- try {
388
- execFileSync(process.execPath, [sCli, ...aArgs], {
389
- env: {
390
- ...process.env,
391
- ...oEnv
392
- },
393
- stdio: "pipe"
394
- });
395
- } catch (oError) {
396
- const sOutput = oError instanceof Error && "stderr" in oError && "stdout" in oError ? `${oError.stderr}${oError.stdout}`.trim() : "";
397
- throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput || Formatter.error(oError)}`);
398
- }
399
- }
400
- };
401
317
  // src/database/Database.ts
402
- /** 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` */
403
319
  var Database = class {
404
- static oAdapter;
320
+ static oPool;
405
321
  /** Mounted clients, by name */
406
322
  static oClients = {};
407
- /** Initializes the Database client */
408
- static async init() {
409
- const sUrl = process.env.DATABASE_URL;
410
- if (!sUrl) throw new Error(MISSING_DATABASE_URL);
411
- const { PrismaPg } = await import("@prisma/adapter-pg");
412
- this.oAdapter = new PrismaPg({ connectionString: sUrl });
413
- }
414
323
  /** Gets a client by name */
415
324
  static get(sName) {
416
325
  return this.oClients[sName];
417
326
  }
418
- /** Generates and mounts the Prisma client of a schema; an official plugin's is also migrated */
419
- static async mount(sName, { schemaPath }) {
420
- if (this.get(sName)) throw new Error(`[nano-app] [ERROR] The database "${sName}" is already mounted.`);
421
- const isPlugin = this.isOfficial(schemaPath);
422
- const aSchemas = this.declaredSchemas(schemaPath);
423
- const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
424
- const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
425
- if (isPlugin ? isPluginInvalid : isConsumerInvalid) throw new Error(`[nano-app] [ERROR] ${schemaPath}: ${isPlugin ? "a plugin declares only nano_* schemas" : "the nano_* schemas are reserved for plugins"}.`);
426
- Prisma.generate(sName, schemaPath);
427
- if (isPlugin) Prisma.migrate(sName, schemaPath, aSchemas[0]);
428
- if (!this.oAdapter) await this.init();
429
- const sClientPath = join(Prisma.dir(sName), "generated", "client.ts");
430
- const PrismaClient = await FileLoader.findPrismaClient(sClientPath, schemaPath);
431
- let oClient;
432
- try {
433
- oClient = new PrismaClient({ adapter: this.oAdapter });
434
- } catch (oError) {
435
- throw new Error(`[nano-app] [ERROR] Cannot instantiate the database "${sName}" from ${sClientPath}: ${Formatter.error(oError)}`);
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 }) {
329
+ if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
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}"`}.`);
436
338
  }
437
- const sFix = isPlugin ? "Update the plugin or report it." : "Apply your schema with Prisma (db push or migrations).";
438
- for (const [, sModel] of FileReader.loadSchema(schemaPath).matchAll(/^\s*model\s+(\w+)/gm)) await oClient[`${sModel[0].toLowerCase()}${sModel.slice(1)}`].findFirst().catch((oError) => {
439
- const sReason = Formatter.error(oError).trim().split("\n").pop();
440
- throw new Error(`[nano-app] [ERROR] The database "${sName}" is not ready: ${sReason} ${sFix}`);
339
+ this.oPool ??= await this.pool();
340
+ const oClient = drizzle({
341
+ client: this.oPool,
342
+ relations: schema.relations ?? defineRelations(schema)
441
343
  });
442
344
  this.oClients[sName] = oClient;
443
- console.log(`[nano-app] [INFO] Database "${sName}" mounted`);
345
+ Logger.info(`Database "${sName}" mounted`);
444
346
  return oClient;
445
347
  }
446
- /** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
447
- static declaredSchemas(sSchemaPath) {
448
- return [...(FileReader.loadSchema(sSchemaPath).match(/datasource\s+\w+\s*\{[^}]*schemas\s*=\s*\[([^\]]*)\]/s)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
449
- }
450
- /** Whether a schema ships with the official plugins: its nearest package.json is theirs */
451
- static isOfficial(sSchemaPath) {
452
- let sDir = dirname(FileReader.getAbsolutePath(sSchemaPath));
453
- while (!existsSync(join(sDir, "package.json"))) {
454
- if (dirname(sDir) === sDir) return false;
455
- sDir = dirname(sDir);
456
- }
457
- 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 });
458
354
  }
459
355
  };
460
356
  // src/routes/Plugin.ts
@@ -470,10 +366,10 @@ var Plugin = class {
470
366
  /** Mounts its endpoints on express app */
471
367
  async mount(oApp) {
472
368
  const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
473
- const oDb = database?.enabled ? await Database.mount(name, database) : Database.get("app");
369
+ const oDb = database ? await Database.mount(name, database) : Database.get("app");
474
370
  if (this.oAuthOptions) {
475
- if (!oDb) throw new Error("[nano-app] [ERROR] src/auth.ts needs the database of the app: set mainPlugin.database.enabled to true.");
476
- 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);
477
373
  oApp.all(sPath, oHandler);
478
374
  oApp.use(fnSession);
479
375
  }
@@ -500,7 +396,7 @@ const INITIAL_CONFIG = {
500
396
  packages: {
501
397
  morgan: {
502
398
  tokens: {
503
- timestamp: () => Formatter.logDate(),
399
+ timestamp: () => Logger.date(),
504
400
  path: (oReq) => oReq.path
505
401
  },
506
402
  format: ":timestamp | :method :path :status"
@@ -514,30 +410,22 @@ const INITIAL_CONFIG = {
514
410
  "QUERY"
515
411
  ] },
516
412
  helmet: true,
517
- compression: true,
413
+ compression: false,
518
414
  express: { limit: "10kb" },
519
415
  urlencoded: { extended: false }
520
416
  },
521
417
  /** Main plugin to mount */
522
418
  mainPlugin: {
523
419
  routesDir: "src/routes",
524
- basePath: "",
525
- database: {
526
- enabled: false,
527
- schemaPath: "src/prisma/schema.prisma"
528
- }
420
+ basePath: ""
529
421
  },
530
422
  /** Plugins around the consumer routes */
531
423
  plugins: []
532
424
  };
533
- /** Whether a value is a plain object (`{}`), the only kind merged field by field */
534
- function isPlainObject(oValue) {
535
- return Object.prototype.toString.call(oValue) === "[object Object]";
536
- }
537
425
  /** Merges a value over its default: plain objects key by key at every level; anything else replaces it */
538
426
  function deepMerge(oDefault, oValue) {
539
427
  if (oValue === void 0) return oDefault;
540
- if (!isPlainObject(oDefault) || !isPlainObject(oValue)) return oValue;
428
+ if (!Validator.isObject(oDefault) || !Validator.isObject(oValue)) return oValue;
541
429
  const aKeys = Object.keys({
542
430
  ...oDefault,
543
431
  ...oValue
@@ -546,12 +434,12 @@ function deepMerge(oDefault, oValue) {
546
434
  }
547
435
  /** Whether a merged configuration kept the plain object of the initial one */
548
436
  function isMergedConfig(oValue) {
549
- return isPlainObject(oValue);
437
+ return Validator.isObject(oValue);
550
438
  }
551
439
  /** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
552
440
  function mergeConfig(oConfig = {}) {
553
441
  const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
554
- if (!isMergedConfig(oMerged)) throw new Error("[nano-app] [ERROR] The configuration of createNanoApp must be an object.");
442
+ if (!isMergedConfig(oMerged)) throw new NanoError("The configuration of createNanoApp must be an object.");
555
443
  return {
556
444
  ...oMerged,
557
445
  mainPlugin: {
@@ -561,26 +449,30 @@ function mergeConfig(oConfig = {}) {
561
449
  };
562
450
  }
563
451
  // src/app/errors.ts
564
- /** Prisma errors the request caused; their own messages name tables and columns */
565
- const PRISMA_ERRORS = {
566
- P2025: {
567
- status: 404,
568
- message: "Not found"
569
- },
570
- 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": {
571
455
  status: 409,
572
456
  message: "Conflict"
573
457
  },
574
- P2003: {
458
+ "23503": {
575
459
  status: 409,
576
460
  message: "Conflict"
577
461
  },
578
- PrismaClientValidationError: {
462
+ "23502": {
463
+ status: 400,
464
+ message: "Invalid request"
465
+ },
466
+ "22P02": {
467
+ status: 400,
468
+ message: "Invalid request"
469
+ },
470
+ DrizzleError: {
579
471
  status: 400,
580
472
  message: "Invalid request"
581
473
  }
582
474
  };
583
- /** 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 */
584
476
  function isHandlerError(oError) {
585
477
  return typeof oError === "object" && oError !== null;
586
478
  }
@@ -594,7 +486,8 @@ function normalize(oError) {
594
486
  }
595
487
  };
596
488
  const oThrown = isHandlerError(oError) ? oError : {};
597
- 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;
598
491
  const iStatus = status ?? 500;
599
492
  return {
600
493
  status: iStatus,
@@ -607,15 +500,12 @@ function normalize(oError) {
607
500
  }
608
501
  /** Turns every unmatched request into a 404 for the error handler */
609
502
  const notFoundHandler = (oReq, _oRes, fnNext) => {
610
- fnNext({
611
- status: 404,
612
- message: `${oReq.path} not found`
613
- });
503
+ fnNext(new NanoError(`${oReq.path} not found`, 404));
614
504
  };
615
505
  /** Turns any error raised by a handler into an ApiResponse */
616
506
  const errorHandler = (oError, _oReq, oRes, _fnNext) => {
617
507
  const { status, body } = normalize(oError);
618
- if (status >= 500) console.error("[nano-app] [ERROR]", oError);
508
+ if (status >= 500) Logger.error("Internal server error", oError);
619
509
  oRes.status(status).json(body);
620
510
  };
621
511
  // src/app/NanoApp.ts
@@ -634,11 +524,10 @@ var NanoApp = class {
634
524
  await this.initialize();
635
525
  this.oApp.listen(Number(this.oConfig.port), () => {
636
526
  this.oConfig.listener?.();
637
- console.log(`\n[nano-app] [INFO] Nano App running on port ${this.oConfig.port}\n`);
527
+ Logger.info(`Nano App running on port ${this.oConfig.port}`);
638
528
  });
639
- return this;
640
529
  } catch (oError) {
641
- console.error("[nano-app] [ERROR] Error trying to start the server.", oError);
530
+ Logger.error("Error trying to start the server.", oError);
642
531
  process.exit(1);
643
532
  }
644
533
  }
@@ -664,11 +553,14 @@ var NanoApp = class {
664
553
  for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
665
554
  this.oApp.use(morgan(oConfig.morgan.format));
666
555
  }
667
- if (oConfig.cors) this.oApp.use(cors(oConfig.cors));
668
- if (oConfig.helmet) this.oApp.use(helmet());
669
- if (oConfig.compression) this.oApp.use(compression());
670
- if (oConfig.express) this.oApp.use(express.json(oConfig.express));
671
- if (oConfig.urlencoded) this.oApp.use(express.urlencoded(oConfig.urlencoded));
556
+ const aPackages = [
557
+ oConfig.cors && cors(oConfig.cors),
558
+ oConfig.helmet && helmet(),
559
+ oConfig.compression && compression(),
560
+ oConfig.express && express.json(oConfig.express),
561
+ oConfig.urlencoded && express.urlencoded(oConfig.urlencoded)
562
+ ];
563
+ for (const fnPackage of aPackages) if (fnPackage) this.oApp.use(fnPackage);
672
564
  }
673
565
  };
674
566
  // src/app/creators.ts
@@ -687,4 +579,4 @@ function createPlugin(fnCreator) {
687
579
  function createPluginCallbacks(oCallbacks) {
688
580
  return oCallbacks;
689
581
  }
690
- export { NanoApp, createNanoApp, createPlugin, createPluginCallbacks, createRoute, isGranted };
582
+ export { NanoApp, NanoError, createNanoApp, createPlugin, createPluginCallbacks, createRoute, isGranted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/backend",
3
- "version": "0.14.1",
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"