@nano-app/backend 0.15.0 → 0.17.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
@@ -48,7 +48,7 @@ export default createRoute({
48
48
 
49
49
  ## Routes
50
50
 
51
- Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
51
+ Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). A route file holds only those methods and its options (`expression`, `public`, `permissions`, `callbacks`): any other key, or an invalid value, stops the startup with the file and what is wrong. `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
52
52
 
53
53
  ```ts
54
54
  export default createRoute({
@@ -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
@@ -2,6 +2,7 @@ import express, { Express, Request, Request as Request$1 } from "express";
2
2
  import { CorsOptions } from "cors";
3
3
  import "dotenv/config";
4
4
  import "jiti";
5
+ import { z } from "zod";
5
6
  import "better-auth";
6
7
  /** API response */
7
8
  type ApiResponse<T = any> = {
@@ -17,13 +18,13 @@ type ApiResponse<T = any> = {
17
18
  type ApiPromise<T = any> = Promise<ApiResponse<T>>;
18
19
  /** Query parameters, sent as the JSON body of a `QUERY` request */
19
20
  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;
21
+ /** Drizzle's relational filter: `{ title: { like: 'a%' } }` */
22
+ where?: NanoObject;
23
+ /** Columns to answer: `{ id: true, title: true }` */
24
+ columns?: NanoObject<boolean>;
25
+ orderBy?: NanoObject<'asc' | 'desc'>;
26
+ limit?: number;
27
+ offset?: number;
27
28
  };
28
29
  /** Extra params of handlers and callbacks: what the app injects (options, `db`) and the framework adds */
29
30
  type Params<Deps = NanoObject<any>> = Deps & {
@@ -55,7 +56,7 @@ type Callback<Deps> = (params: Params<Deps>) => Promise<void>;
55
56
  type CallbackMethod<Deps> = (req: Request$1, method: string, params: Params<Deps>) => Promise<NanoObject>;
56
57
  /** Callback run with the Express app */
57
58
  type CallbackMount<Deps> = (app: Express, params: Params<Deps>) => Promise<void>;
58
- /** Callbacks of an endpoint. They never receive the app: what affects the whole app goes in the plugin's onMount */
59
+ /** Callbacks of an endpoint; what affects the whole app goes in the plugin's onMount */
59
60
  type Callbacks<Deps = NanoObject<any>> = {
60
61
  /** Run once, when the endpoint is mounted */
61
62
  onInit?: Callback<Deps>;
@@ -79,7 +80,7 @@ type RouteEntry<Deps = NanoObject<any>> = {
79
80
  } & Handlers<Deps>;
80
81
  /** Generic object */
81
82
  type NanoObject<T = any> = Record<string, T>;
82
- /** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
83
+ /** Error of the framework, with the status a handler answers */
83
84
  declare class NanoError extends Error {
84
85
  status?: number;
85
86
  /** Error with its message, and the status a handler answers (500 without one) */
@@ -87,12 +88,10 @@ declare class NanoError extends Error {
87
88
  /** Prefix of the framework's messages, shown before the message */
88
89
  get name(): string;
89
90
  }
90
- /** Database client configuration. */
91
+ /** Database of a plugin or of the app */
91
92
  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;
93
+ /** Its Drizzle schema: the module of its tables and `relations` */
94
+ schema: NanoObject;
96
95
  };
97
96
  /** When a plugin is mounted, relative to the consumer routes */
98
97
  type PluginStage = 'before' | 'after';
@@ -106,31 +105,15 @@ type PluginConfig<Options = NanoObject> = {
106
105
  basePath?: string;
107
106
  /** Mount point relative to the consumer routes. Defaults to "before" */
108
107
  stage?: PluginStage;
109
- /** Prisma client the plugin needs, mounted before its endpoints */
108
+ /** Drizzle schema of the plugin's database, mounted before its endpoints */
110
109
  database?: DatabaseConfig;
111
110
  /** Plugin options */
112
111
  options?: Options;
113
112
  /** Plugin callbacks */
114
113
  callbacks?: PluginCallbacks;
115
114
  };
116
- /** The project's plugin: nano-app names it "app" and mounts it between the "before" and "after" plugins */
117
- type MainPluginConfig<Options = NanoObject> = {
118
- /** Directory of the project's `*.route.ts` files. Defaults to "src/routes" */
119
- routesDir?: string;
120
- /** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
121
- 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
- };
129
- /** Plugin options */
130
- options?: Options;
131
- /** Plugin callbacks */
132
- callbacks?: PluginCallbacks;
133
- };
115
+ /** The project's plugin, named "app": `routesDir` defaults to "src/routes" */
116
+ type MainPluginConfig<Options = NanoObject> = Partial<Omit<PluginConfig<Options>, 'name' | 'stage'>>;
134
117
  /** Configuration of the framework's built-in packages */
135
118
  type PackagesConfig = {
136
119
  /** Morgan configuration */
@@ -167,7 +150,7 @@ type NanoAppConfig = {
167
150
  mainPlugin?: MainPluginConfig;
168
151
  };
169
152
  /** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
170
- declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'settings' | 'plugins' | 'packages'>> & {
153
+ declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'port' | 'settings' | 'plugins' | 'packages'>> & {
171
154
  mainPlugin: PluginConfig;
172
155
  };
173
156
  /** Main NanoApp class */
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,13 +5,12 @@ 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";
11
+ import { z } from "zod";
14
12
  // ../core/utils/NanoError.ts
15
- /** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
13
+ /** Error of the framework, with the status a handler answers */
16
14
  var NanoError = class extends Error {
17
15
  status;
18
16
  /** Error with its message, and the status a handler answers (500 without one) */
@@ -27,31 +25,13 @@ var NanoError = class extends Error {
27
25
  };
28
26
  // ../core/files/FileReader.ts
29
27
  /** 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"] });
28
+ var FileReader = class {
29
+ /** Loader of the consumer's modules */
30
+ static oJiti = createJiti(import.meta.url);
33
31
  /** Loads a module via jiti */
34
32
  static async loadModule(sFilePath, oOptions = {}) {
35
33
  return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
36
34
  }
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
35
  /** Resolves the absolute path of a file */
56
36
  static getAbsolutePath(sFilePath) {
57
37
  const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
@@ -62,11 +42,7 @@ var FileReader = class FileReader {
62
42
  // ../core/files/FileLoader.ts
63
43
  /** Finds and loads the consumer's files */
64
44
  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
- /** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
45
+ /** Finds the `*.<extension>.ts` files of a folder: `{ [subPath]: defaultExport }` */
70
46
  static async findMany(sExtension, sSourceDir) {
71
47
  const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
72
48
  const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
@@ -82,101 +58,20 @@ var FileLoader = class {
82
58
  return oEntries;
83
59
  }
84
60
  };
85
- // ../core/const.ts
86
- /** HTTP methods a route can handle */
87
- const HTTP_METHODS = [
88
- "GET",
89
- "POST",
90
- "PUT",
91
- "DELETE",
92
- "QUERY"
93
- ];
94
- /** Package of the official plugins: only its schemas may declare nano_* */
95
- const OFFICIAL_PLUGINS = "@nano-app/plugins";
96
- /** Loopback and private networks: the proxies the backend trusts by default */
97
- const PRIVATE_NETWORKS = [
98
- "127.0.0.0/8",
99
- "::1/128",
100
- "10.0.0.0/8",
101
- "172.16.0.0/12",
102
- "192.168.0.0/16",
103
- "fc00::/7"
104
- ];
105
- // ../core/utils/Paths.ts
106
- /** Paths of the app's pages */
107
- var Paths = class {
108
- /** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
109
- static matches(sPattern, sPath) {
110
- if (!sPattern.endsWith("/*")) return sPattern === sPath;
111
- const sBase = sPattern.slice(0, -2);
112
- return sPath === sBase || sPath.startsWith(`${sBase}/`);
113
- }
114
- };
115
61
  // ../core/utils/Validator.ts
116
- const STORE_TYPES = ["app", "api"];
117
62
  /** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
118
- var Validator = class Validator {
63
+ var Validator = class {
119
64
  /** Whether a value is a plain object */
120
65
  static isObject(oValue) {
121
66
  return Object.prototype.toString.call(oValue) === "[object Object]";
122
67
  }
123
- /** Whether a value is a plain object of strings */
124
- static isStringMap(oValue) {
125
- return Validator.isObject(oValue) && Object.values(oValue).every((sItem) => typeof sItem === "string");
126
- }
127
- /** Whether a value is a path that starts with `/` */
128
- static isPath(oValue) {
129
- return typeof oValue === "string" && oValue.startsWith("/");
130
- }
131
- /** First environment variable of a list that is not defined */
132
- static missingEnv(aNames) {
133
- return aNames.find((sName) => !process.env[sName]);
134
- }
135
- /** Checks every entry with a guard (`Validator.isRoute`...): the first invalid one throws */
136
- static validate(oEntries, fnGuard) {
137
- return Object.fromEntries(Object.entries(oEntries).filter(([sKey, oEntry]) => fnGuard(oEntry, sKey)));
138
- }
139
- /** Type guard for a `*.route.ts` entry */
140
- static isRoute(oEntry, sFilePath) {
141
- if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
142
- if (oEntry.expression && typeof oEntry.expression !== "string") throw new NanoError(`${sFilePath}: "expression" must be a string.`);
143
- if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") throw new NanoError(`${sFilePath}: "public" must be a boolean.`);
144
- for (const sMethod of HTTP_METHODS) {
145
- const oMethod = oEntry[sMethod];
146
- if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") throw new NanoError(`${sFilePath}: "${sMethod}" must be a function or { handler, permissions }.`);
147
- }
148
- if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) throw new NanoError(`${sFilePath}: defines no HTTP method.`);
149
- return true;
150
- }
151
- /** Type guard for a `*.store.ts` entry */
152
- static isStore(oEntry, sFilePath) {
153
- if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
154
- if (!STORE_TYPES.includes(oEntry.type)) throw new NanoError(`${sFilePath}: its type must be "app" or "api".`);
155
- if (oEntry.data !== void 0 && !Validator.isObject(oEntry.data)) throw new NanoError(`${sFilePath}: "data" must be an object.`);
156
- if (oEntry.headers !== void 0 && !Validator.isStringMap(oEntry.headers)) throw new NanoError(`${sFilePath}: "headers" must be an object of strings.`);
157
- 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.`);
158
- return true;
159
- }
160
- /** Whether `auth` has the base path of Better Auth and valid routes, none redirecting to itself */
161
- static isAuth(oAuth) {
162
- if (!Validator.isObject(oAuth) || typeof oAuth.path !== "string") return false;
163
- const aRoutes = oAuth.routes ?? [];
164
- if (!Array.isArray(aRoutes)) return false;
165
- return aRoutes.every((oRoute) => {
166
- if (!Validator.isObject(oRoute)) return false;
167
- const { match, redirect = "/", roles, permissions } = oRoute;
168
- return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
169
- });
170
- }
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;
68
+ /** Checks every entry with a zod schema (`ROUTE`, `STORE`): the first invalid one throws with its key */
69
+ static validate(oEntries, oSchema) {
70
+ return Object.fromEntries(Object.entries(oEntries).map(([sKey, oEntry]) => {
71
+ const oResult = oSchema.safeParse(oEntry);
72
+ if (!oResult.success) throw new NanoError(`${sKey}:\n${z.prettifyError(oResult.error)}`);
73
+ return [sKey, oResult.data];
74
+ }));
180
75
  }
181
76
  };
182
77
  // ../core/utils/Logger.ts
@@ -203,55 +98,69 @@ var Logger = class Logger {
203
98
  return oDate.toISOString().slice(0, 19).replace("T", " ");
204
99
  }
205
100
  };
206
- // src/auth/BetterAuth.ts
207
- /** Files of the app's Better Auth options, relative to `cwd`: source, then built */
208
- 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 */
210
- var BetterAuth = class {
211
- static oAuth;
212
- /** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
213
- static async findOptions() {
214
- const sFilePath = AUTH_FILES.find(existsSync);
215
- if (!sFilePath) return void 0;
216
- const oFile = await FileReader.loadModule(sFilePath, { default: true });
217
- if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
218
- return oFile.options;
219
- }
220
- /** The instance, once the app's plugin created it */
221
- static get() {
222
- return this.oAuth;
223
- }
224
- /** Creates the instance on the app's client, its handler and the session lookup */
225
- static async create(oOptions, oDb) {
226
- const { betterAuth } = await import("better-auth");
227
- const { prismaAdapter } = await import("better-auth/adapters/prisma");
228
- const { getSchema } = await import("better-auth/db");
229
- 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.`);
232
- const oAuth = betterAuth({
233
- ...oOptions,
234
- advanced: {
235
- ...oOptions.advanced,
236
- ipAddress: {
237
- trustedProxies: PRIVATE_NETWORKS,
238
- ...oOptions.advanced?.ipAddress
239
- }
240
- },
241
- database: prismaAdapter(oDb, { provider: "postgresql" })
242
- });
243
- this.oAuth = oAuth;
244
- const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
245
- oRes.locals.session = oSession ?? void 0;
246
- fnNext();
247
- });
248
- return {
249
- oHandler: toNodeHandler(oAuth),
250
- fnSession,
251
- sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
252
- };
101
+ // ../core/utils/Paths.ts
102
+ /** Paths of the app's pages */
103
+ var Paths = class {
104
+ /** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
105
+ static matches(sPattern, sPath) {
106
+ if (!sPattern.endsWith("/*")) return sPattern === sPath;
107
+ const sBase = sPattern.slice(0, -2);
108
+ return sPath === sBase || sPath.startsWith(`${sBase}/`);
253
109
  }
254
110
  };
111
+ // ../core/const.ts
112
+ /** Loopback and private networks: the proxies the backend trusts by default */
113
+ const PRIVATE_NETWORKS = [
114
+ "127.0.0.0/8",
115
+ "::1/128",
116
+ "10.0.0.0/8",
117
+ "172.16.0.0/12",
118
+ "192.168.0.0/16",
119
+ "fc00::/7"
120
+ ];
121
+ // ../core/utils/validations.ts
122
+ /** A function of the consumer, typed as what the framework calls */
123
+ function callable() {
124
+ return z.custom((oValue) => typeof oValue === "function");
125
+ }
126
+ /** Permissions of Better Auth's admin plugin: the actions per resource */
127
+ const PERMISSIONS = z.record(z.string(), z.array(z.string())).optional();
128
+ /** A method of a route: its handler, or it with its own permissions */
129
+ const METHOD = z.union([callable(), z.object({
130
+ handler: callable(),
131
+ permissions: PERMISSIONS
132
+ })]).optional();
133
+ /** A `*.route.ts` file: its options, its callbacks and at least one HTTP method, nothing else */
134
+ const ROUTE = z.strictObject({
135
+ expression: z.string().optional(),
136
+ public: z.boolean().optional(),
137
+ permissions: PERMISSIONS,
138
+ callbacks: z.object({
139
+ onInit: callable().optional(),
140
+ onBefore: callable().optional()
141
+ }).optional(),
142
+ GET: METHOD,
143
+ POST: METHOD,
144
+ PUT: METHOD,
145
+ DELETE: METHOD,
146
+ QUERY: METHOD
147
+ }).refine(({ GET, POST, PUT, DELETE, QUERY }) => GET ?? POST ?? PUT ?? DELETE ?? QUERY, { error: "It defines no HTTP method" });
148
+ /** A path of the app, starting with `/` */
149
+ const PATH = z.string().startsWith("/");
150
+ z.object({
151
+ type: z.enum(["app", "api"]),
152
+ data: z.record(z.string(), z.any()).optional(),
153
+ headers: z.record(z.string(), z.string()).optional(),
154
+ auth: z.object({
155
+ path: z.string(),
156
+ routes: z.array(z.object({
157
+ match: PATH,
158
+ redirect: PATH.optional(),
159
+ roles: z.string().optional(),
160
+ permissions: z.record(z.string(), z.string()).optional()
161
+ }).refine(({ match, redirect }) => !Paths.matches(match, redirect ?? "/"), { error: "Its \"redirect\" must be outside its \"match\"" })).optional()
162
+ }).optional()
163
+ });
255
164
  // src/auth/permissions.ts
256
165
  /** Whether the session's user has the permissions; always without Better Auth or permissions */
257
166
  async function isGranted({ auth, session }, oPermissions) {
@@ -291,9 +200,8 @@ var Endpoint = class {
291
200
  createHandler(oParams) {
292
201
  return (oReq, oRes, fnNext) => {
293
202
  if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
294
- const sMethod = oReq.method.toUpperCase();
295
- const sHandled = HTTP_METHODS.find((sHttpMethod) => sHttpMethod === sMethod);
296
- const oMethod = sHandled && this.oMethods[sHandled];
203
+ const sMethod = oReq.method;
204
+ const oMethod = this.oMethods[sMethod];
297
205
  if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
298
206
  const oRequestParams = {
299
207
  ...oParams,
@@ -329,7 +237,7 @@ var Router = class {
329
237
  async mount() {
330
238
  if (!this.sRoutesDir) return [];
331
239
  const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
332
- const oRoutes = Validator.validate(oFiles, Validator.isRoute);
240
+ const oRoutes = Validator.validate(oFiles, ROUTE);
333
241
  return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
334
242
  const sPath = this.createPath(sFilePath, expression);
335
243
  Logger.info(sPath);
@@ -348,130 +256,119 @@ var Router = class {
348
256
  return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
349
257
  }
350
258
  };
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
259
+ // src/auth/BetterAuth.ts
260
+ /** Files of the app's Better Auth options, relative to `cwd`: source, then built */
261
+ const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
262
+ /** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
263
+ var BetterAuth = class {
264
+ static oAuth;
265
+ /** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
266
+ static async findOptions() {
267
+ const sFilePath = AUTH_FILES.find(existsSync);
268
+ if (!sFilePath) return void 0;
269
+ const oFile = await FileReader.loadModule(sFilePath, { default: true });
270
+ if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
271
+ return oFile.options;
272
+ }
273
+ /** The instance, once the app's plugin created it */
274
+ static get() {
275
+ return this.oAuth;
276
+ }
277
+ /** Creates the instance on the app's client and schema, its handler and the session lookup */
278
+ static async create(oOptions, oDb, oSchema) {
279
+ const { betterAuth } = await import("better-auth");
280
+ const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
281
+ const { getSchema } = await import("better-auth/db");
282
+ const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
283
+ const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
284
+ 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.`);
285
+ const oAuth = betterAuth({
286
+ ...oOptions,
287
+ advanced: {
288
+ ...oOptions.advanced,
289
+ ipAddress: {
290
+ trustedProxies: PRIVATE_NETWORKS,
291
+ ...oOptions.advanced?.ipAddress
292
+ }
410
293
  },
411
- stdio: "pipe"
294
+ database: drizzleAdapter(oDb, {
295
+ provider: "pg",
296
+ schema: oSchema
297
+ })
298
+ });
299
+ this.oAuth = oAuth;
300
+ const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
301
+ oRes.locals.session = oSession ?? void 0;
302
+ fnNext();
412
303
  });
304
+ return {
305
+ oHandler: toNodeHandler(oAuth),
306
+ fnSession,
307
+ sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
308
+ };
413
309
  }
414
310
  };
415
311
  // src/database/Database.ts
416
- /** Prisma clients of the app and its plugins, one per name */
312
+ /** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
417
313
  var Database = class {
418
- static oAdapter;
314
+ static oPool;
419
315
  /** Mounted clients, by name */
420
316
  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
317
  /** Gets a client by name */
429
318
  static get(sName) {
430
319
  return this.oClients[sName];
431
320
  }
432
- /** Generates and mounts the Prisma client of a schema; an official plugin's is also migrated */
433
- static async mount(sName, { schemaPath }) {
321
+ /** Mounts the Drizzle client of a schema, checking the `nano_*` rule of its tables */
322
+ static async mount(sName, { schema }) {
434
323
  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();
324
+ const { defineRelations, is } = await import("drizzle-orm");
325
+ const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
326
+ const { drizzle } = await import("drizzle-orm/node-postgres");
327
+ const sOwn = `nano_${sName}`;
328
+ const { breaks, rule } = sName === "app" ? {
329
+ breaks: (sSchema) => sSchema.startsWith("nano_"),
330
+ rule: "the nano_* schemas are reserved for plugins"
331
+ } : {
332
+ breaks: (sSchema) => sSchema !== sOwn,
333
+ rule: `a plugin keeps its tables in "${sOwn}"`
334
+ };
335
+ for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
336
+ const { name, schema: sSchema = "public" } = getTableConfig(oTable);
337
+ if (breaks(sSchema)) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${rule}.`);
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
459
357
  /** A group of routes, with its options, callbacks and database */
460
358
  var Plugin = class {
461
359
  oConfig;
462
- oAuthOptions;
463
- /** Creates a new Plugin instance; the app's plugin also gets the Better Auth options */
464
- constructor(oConfig, oAuthOptions) {
360
+ /** Creates a new Plugin instance */
361
+ constructor(oConfig) {
465
362
  this.oConfig = oConfig;
466
- this.oAuthOptions = oAuthOptions;
467
363
  }
468
364
  /** Mounts its endpoints on express app */
469
365
  async mount(oApp) {
470
366
  const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
471
- const oDb = database?.enabled ? await Database.mount(name, database) : Database.get("app");
472
- 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);
367
+ const oAuthOptions = name === "app" ? await BetterAuth.findOptions() : void 0;
368
+ const oDb = database ? await Database.mount(name, database) : Database.get("app");
369
+ if (oAuthOptions) {
370
+ if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
371
+ const { sPath, oHandler, fnSession } = await BetterAuth.create(oAuthOptions, oDb, database.schema);
475
372
  oApp.all(sPath, oHandler);
476
373
  oApp.use(fnSession);
477
374
  }
@@ -490,8 +387,6 @@ var Plugin = class {
490
387
  const INITIAL_CONFIG = {
491
388
  /** Application port */
492
389
  port: 3e3,
493
- /** Application listener */
494
- listener: () => {},
495
390
  /** Express settings: the client IP comes from `X-Forwarded-For` only through private proxies */
496
391
  settings: { "trust proxy": PRIVATE_NETWORKS },
497
392
  /** Initial middlewares */
@@ -519,11 +414,7 @@ const INITIAL_CONFIG = {
519
414
  /** Main plugin to mount */
520
415
  mainPlugin: {
521
416
  routesDir: "src/routes",
522
- basePath: "",
523
- database: {
524
- enabled: false,
525
- schemaPath: "src/prisma/schema.prisma"
526
- }
417
+ basePath: ""
527
418
  },
528
419
  /** Plugins around the consumer routes */
529
420
  plugins: []
@@ -555,26 +446,30 @@ function mergeConfig(oConfig = {}) {
555
446
  };
556
447
  }
557
448
  // 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: {
449
+ /** Database errors the request caused, by their Postgres code or Drizzle's name */
450
+ const DATABASE_ERRORS = {
451
+ "23505": {
565
452
  status: 409,
566
453
  message: "Conflict"
567
454
  },
568
- P2003: {
455
+ "23503": {
569
456
  status: 409,
570
457
  message: "Conflict"
571
458
  },
572
- PrismaClientValidationError: {
459
+ "23502": {
460
+ status: 400,
461
+ message: "Invalid request"
462
+ },
463
+ "22P02": {
464
+ status: 400,
465
+ message: "Invalid request"
466
+ },
467
+ DrizzleError: {
573
468
  status: 400,
574
469
  message: "Invalid request"
575
470
  }
576
471
  };
577
- /** Whether anything thrown is an object, read as a HandlerError or a Prisma error */
472
+ /** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
578
473
  function isHandlerError(oError) {
579
474
  return typeof oError === "object" && oError !== null;
580
475
  }
@@ -588,7 +483,8 @@ function normalize(oError) {
588
483
  }
589
484
  };
590
485
  const oThrown = isHandlerError(oError) ? oError : {};
591
- const { status, message, warning } = PRISMA_ERRORS[oThrown.code ?? oThrown.name ?? ""] ?? oThrown;
486
+ const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
487
+ const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
592
488
  const iStatus = status ?? 500;
593
489
  return {
594
490
  status: iStatus,
@@ -623,7 +519,7 @@ var NanoApp = class {
623
519
  async start() {
624
520
  try {
625
521
  await this.initialize();
626
- this.oApp.listen(Number(this.oConfig.port), () => {
522
+ this.oApp.listen(this.oConfig.port, () => {
627
523
  this.oConfig.listener?.();
628
524
  Logger.info(`Nano App running on port ${this.oConfig.port}`);
629
525
  });
@@ -636,20 +532,22 @@ var NanoApp = class {
636
532
  async initialize() {
637
533
  for (const [sKey, oValue] of Object.entries(this.oConfig.settings)) this.oApp.set(sKey, oValue);
638
534
  this.setPackages(this.oConfig.packages);
535
+ const { mainPlugin, plugins } = this.oConfig;
639
536
  const oPlugins = {
640
537
  before: [],
641
538
  after: []
642
539
  };
643
- for (const oPlugin of this.oConfig.plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
644
- for (const oPlugin of oPlugins.before) await new Plugin(oPlugin).mount(this.oApp);
645
- const { mainPlugin } = this.oConfig;
646
- await new Plugin(mainPlugin, await BetterAuth.findOptions()).mount(this.oApp);
647
- for (const oPlugin of oPlugins.after) await new Plugin(oPlugin).mount(this.oApp);
540
+ for (const oPlugin of plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
541
+ for (const oPlugin of [
542
+ ...oPlugins.before,
543
+ mainPlugin,
544
+ ...oPlugins.after
545
+ ]) await new Plugin(oPlugin).mount(this.oApp);
648
546
  this.oApp.use(notFoundHandler);
649
547
  this.oApp.use(errorHandler);
650
548
  }
651
549
  /** Adds the packages according to the configuration */
652
- setPackages(oConfig = {}) {
550
+ setPackages(oConfig) {
653
551
  if (oConfig.morgan) {
654
552
  for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
655
553
  this.oApp.use(morgan(oConfig.morgan.format));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/backend",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",
@@ -50,22 +50,19 @@
50
50
  "glob": "^14.0.4",
51
51
  "helmet": "^8.3.0",
52
52
  "jiti": "^2.7.0",
53
- "morgan": "^1.12.1"
53
+ "morgan": "^1.12.1",
54
+ "zod": "^4.6.7"
54
55
  },
55
56
  "peerDependencies": {
56
- "@prisma/adapter-pg": "^7.10.1",
57
- "@prisma/client": "^7.10.1",
58
57
  "better-auth": "^1.7.18",
58
+ "drizzle-orm": "^1.0.0-rc.5",
59
59
  "pg": "^8.23.1"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
- "@prisma/adapter-pg": {
63
- "optional": true
64
- },
65
- "@prisma/client": {
62
+ "better-auth": {
66
63
  "optional": true
67
64
  },
68
- "better-auth": {
65
+ "drizzle-orm": {
69
66
  "optional": true
70
67
  },
71
68
  "pg": {
@@ -73,8 +70,6 @@
73
70
  }
74
71
  },
75
72
  "devDependencies": {
76
- "@prisma/adapter-pg": "^7.10.1",
77
- "@prisma/client": "^7.10.1",
78
73
  "@types/compression": "^1.8.1",
79
74
  "@types/cors": "^2.8.19",
80
75
  "@types/express": "^5.0.6",
@@ -82,8 +77,9 @@
82
77
  "@types/node": "^26.6.6",
83
78
  "@types/pg": "^8.20.0",
84
79
  "better-auth": "^1.7.18",
80
+ "drizzle-kit": "^1.0.0-rc.5",
81
+ "drizzle-orm": "^1.0.0-rc.5",
85
82
  "pg": "^8.23.1",
86
- "prisma": "^7.10.1",
87
83
  "tsdown": "^0.23.5",
88
84
  "tsx": "^4.23.15",
89
85
  "typescript": "^7.0.2"