@nano-app/backend 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -49
- package/build/index.d.ts +13 -20
- package/build/index.js +59 -160
- package/package.json +7 -12
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
|
|
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
|
|
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
|
|
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
|
|
120
|
+
pnpm add drizzle-orm pg
|
|
121
|
+
pnpm add -D drizzle-kit
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
```
|
|
124
|
-
// src/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
GET: async (_req, { db }) => ({ result: await db
|
|
146
|
+
export default createRoute({
|
|
147
|
+
GET: async (_req, { db }) => ({ result: await db.query.users.findMany() }),
|
|
152
148
|
});
|
|
153
149
|
```
|
|
154
150
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
-
|
|
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
|
|
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: {
|
|
180
|
+
createNanoApp({ mainPlugin: { database: { schema } } });
|
|
191
181
|
```
|
|
192
182
|
|
|
193
|
-
Its tables are yours, like the rest of your schema. Add
|
|
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
|
|
197
|
-
|
|
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.
|
|
201
|
-
- Startup also fails if your schema lacks a
|
|
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
|
|
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
|
|
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
|
|
306
|
-
//
|
|
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<
|
|
306
|
+
| `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<{ db: MyDb }>` types `db`), e.g. to keep them in their own file. |
|
|
318
307
|
|
|
319
308
|
Also exported: the `NanoApp` class that `createNanoApp` returns, the configuration types (`NanoAppConfig`, `PackagesConfig`, `PluginConfig`, `PluginStage`, `MainPluginConfig`, `DatabaseConfig`), the Express `Request` type and the HTTP contract (`ApiResponse`, `ApiPromise`, `QueryParams`, `RouteEntry`, `Handler`, `Handlers`, `NanoObject`) and the callback types (`Params`, `Callbacks`, `PluginCallbacks`).
|
|
320
309
|
|
package/build/index.d.ts
CHANGED
|
@@ -17,13 +17,13 @@ type ApiResponse<T = any> = {
|
|
|
17
17
|
type ApiPromise<T = any> = Promise<ApiResponse<T>>;
|
|
18
18
|
/** Query parameters, sent as the JSON body of a `QUERY` request */
|
|
19
19
|
type QueryParams = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
/** Drizzle's relational filter: `{ title: { like: 'a%' } }` */
|
|
21
|
+
where?: NanoObject;
|
|
22
|
+
/** Columns to answer: `{ id: true, title: true }` */
|
|
23
|
+
columns?: NanoObject<boolean>;
|
|
24
|
+
orderBy?: NanoObject<'asc' | 'desc'>;
|
|
25
|
+
limit?: number;
|
|
26
|
+
offset?: number;
|
|
27
27
|
};
|
|
28
28
|
/** Extra params of handlers and callbacks: what the app injects (options, `db`) and the framework adds */
|
|
29
29
|
type Params<Deps = NanoObject<any>> = Deps & {
|
|
@@ -87,12 +87,10 @@ declare class NanoError extends Error {
|
|
|
87
87
|
/** Prefix of the framework's messages, shown before the message */
|
|
88
88
|
get name(): string;
|
|
89
89
|
}
|
|
90
|
-
/** Database
|
|
90
|
+
/** Database of a plugin or of the app */
|
|
91
91
|
type DatabaseConfig = {
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
/** Prisma schema file (`.prisma`), relative to `cwd` */
|
|
95
|
-
schemaPath: string;
|
|
92
|
+
/** Its Drizzle schema: the module of its tables, with its `relations` when it has them (`import * as schema from './db/schema'`) */
|
|
93
|
+
schema: NanoObject;
|
|
96
94
|
};
|
|
97
95
|
/** When a plugin is mounted, relative to the consumer routes */
|
|
98
96
|
type PluginStage = 'before' | 'after';
|
|
@@ -106,7 +104,7 @@ type PluginConfig<Options = NanoObject> = {
|
|
|
106
104
|
basePath?: string;
|
|
107
105
|
/** Mount point relative to the consumer routes. Defaults to "before" */
|
|
108
106
|
stage?: PluginStage;
|
|
109
|
-
/**
|
|
107
|
+
/** Drizzle schema of the plugin's database, mounted before its endpoints */
|
|
110
108
|
database?: DatabaseConfig;
|
|
111
109
|
/** Plugin options */
|
|
112
110
|
options?: Options;
|
|
@@ -119,13 +117,8 @@ type MainPluginConfig<Options = NanoObject> = {
|
|
|
119
117
|
routesDir?: string;
|
|
120
118
|
/** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
|
|
121
119
|
basePath?: string;
|
|
122
|
-
/**
|
|
123
|
-
database?:
|
|
124
|
-
/** Whether the database is enabled. Defaults to false */
|
|
125
|
-
enabled?: boolean;
|
|
126
|
-
/** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
|
|
127
|
-
schemaPath?: string;
|
|
128
|
-
};
|
|
120
|
+
/** Drizzle schema of the project's database; without it the app has none */
|
|
121
|
+
database?: DatabaseConfig;
|
|
129
122
|
/** Plugin options */
|
|
130
123
|
options?: Options;
|
|
131
124
|
/** Plugin callbacks */
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import morgan from "morgan";
|
|
4
3
|
import cors from "cors";
|
|
@@ -6,11 +5,9 @@ import helmet from "helmet";
|
|
|
6
5
|
import compression from "compression";
|
|
7
6
|
import "dotenv/config";
|
|
8
7
|
import { glob } from "glob";
|
|
9
|
-
import {
|
|
10
|
-
import { existsSync
|
|
8
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
11
10
|
import { createJiti } from "jiti";
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
14
11
|
// ../core/utils/NanoError.ts
|
|
15
12
|
/** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
|
|
16
13
|
var NanoError = class extends Error {
|
|
@@ -27,31 +24,13 @@ var NanoError = class extends Error {
|
|
|
27
24
|
};
|
|
28
25
|
// ../core/files/FileReader.ts
|
|
29
26
|
/** Reads and loads files from disk, resolved from `cwd` */
|
|
30
|
-
var FileReader = class
|
|
31
|
-
/** Loader of the consumer's modules
|
|
32
|
-
static oJiti = createJiti(import.meta.url
|
|
27
|
+
var FileReader = class {
|
|
28
|
+
/** Loader of the consumer's modules */
|
|
29
|
+
static oJiti = createJiti(import.meta.url);
|
|
33
30
|
/** Loads a module via jiti */
|
|
34
31
|
static async loadModule(sFilePath, oOptions = {}) {
|
|
35
32
|
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
36
33
|
}
|
|
37
|
-
/** Reads the contents of a file */
|
|
38
|
-
static loadFile(sFilePath) {
|
|
39
|
-
const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
|
|
40
|
-
if (!sContent) throw new NanoError(`The file ${sFilePath} does not have valid content.`);
|
|
41
|
-
return sContent;
|
|
42
|
-
}
|
|
43
|
-
/** Reads a Prisma schema without its `//` comments */
|
|
44
|
-
static loadSchema(sFilePath) {
|
|
45
|
-
return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
|
|
46
|
-
}
|
|
47
|
-
/** Body of the `datasource` block of a Prisma schema, without its braces */
|
|
48
|
-
static datasource(sContent) {
|
|
49
|
-
return sContent.match(/datasource\s+\w+\s*\{([^}]*)\}/)?.[1] ?? "";
|
|
50
|
-
}
|
|
51
|
-
/** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
|
|
52
|
-
static schemas(sContent) {
|
|
53
|
-
return [...(FileReader.datasource(sContent).match(/\bschemas\s*=\s*\[([^\]]*)\]/)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
|
|
54
|
-
}
|
|
55
34
|
/** Resolves the absolute path of a file */
|
|
56
35
|
static getAbsolutePath(sFilePath) {
|
|
57
36
|
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
@@ -62,10 +41,6 @@ var FileReader = class FileReader {
|
|
|
62
41
|
// ../core/files/FileLoader.ts
|
|
63
42
|
/** Finds and loads the consumer's files */
|
|
64
43
|
var FileLoader = class {
|
|
65
|
-
/** Loads a generated Prisma client module (`client.ts`, or `client.js`) */
|
|
66
|
-
static async findPrismaClient(sClientPath) {
|
|
67
|
-
return (await FileReader.loadModule(sClientPath))?.PrismaClient;
|
|
68
|
-
}
|
|
69
44
|
/** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
|
|
70
45
|
static async findMany(sExtension, sSourceDir) {
|
|
71
46
|
const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
@@ -91,8 +66,6 @@ const HTTP_METHODS = [
|
|
|
91
66
|
"DELETE",
|
|
92
67
|
"QUERY"
|
|
93
68
|
];
|
|
94
|
-
/** Package of the official plugins: only its schemas may declare nano_* */
|
|
95
|
-
const OFFICIAL_PLUGINS = "@nano-app/plugins";
|
|
96
69
|
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
97
70
|
const PRIVATE_NETWORKS = [
|
|
98
71
|
"127.0.0.0/8",
|
|
@@ -168,16 +141,6 @@ var Validator = class Validator {
|
|
|
168
141
|
return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
|
|
169
142
|
});
|
|
170
143
|
}
|
|
171
|
-
/** Whether a Prisma schema, without comments, is PostgreSQL and declares the schemas it may */
|
|
172
|
-
static isPrisma(sContent, sFilePath, isPlugin) {
|
|
173
|
-
const sProvider = FileReader.datasource(sContent).match(/\bprovider\s*=\s*["']([^"']+)["']/)?.[1];
|
|
174
|
-
if (sProvider !== "postgresql") throw new NanoError(`${sFilePath}: the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
|
|
175
|
-
const aSchemas = FileReader.schemas(sContent);
|
|
176
|
-
const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
|
|
177
|
-
const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
|
|
178
|
-
if (isPlugin ? isPluginInvalid : isConsumerInvalid) throw new NanoError(`${sFilePath}: ${isPlugin ? "a plugin declares only nano_* schemas" : "the nano_* schemas are reserved for plugins"}.`);
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
144
|
};
|
|
182
145
|
// ../core/utils/Logger.ts
|
|
183
146
|
/** Messages of the framework on the console */
|
|
@@ -206,7 +169,7 @@ var Logger = class Logger {
|
|
|
206
169
|
// src/auth/BetterAuth.ts
|
|
207
170
|
/** Files of the app's Better Auth options, relative to `cwd`: source, then built */
|
|
208
171
|
const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
|
|
209
|
-
/** Better Auth on the app's
|
|
172
|
+
/** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
|
|
210
173
|
var BetterAuth = class {
|
|
211
174
|
static oAuth;
|
|
212
175
|
/** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
|
|
@@ -221,14 +184,14 @@ var BetterAuth = class {
|
|
|
221
184
|
static get() {
|
|
222
185
|
return this.oAuth;
|
|
223
186
|
}
|
|
224
|
-
/** Creates the instance on the app's client, its handler and the session lookup */
|
|
225
|
-
static async create(oOptions, oDb) {
|
|
187
|
+
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
188
|
+
static async create(oOptions, oDb, oSchema) {
|
|
226
189
|
const { betterAuth } = await import("better-auth");
|
|
227
|
-
const {
|
|
190
|
+
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
228
191
|
const { getSchema } = await import("better-auth/db");
|
|
229
192
|
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
230
|
-
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !
|
|
231
|
-
if (aMissing.length) throw new NanoError(`Better Auth needs the
|
|
193
|
+
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
194
|
+
if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
|
|
232
195
|
const oAuth = betterAuth({
|
|
233
196
|
...oOptions,
|
|
234
197
|
advanced: {
|
|
@@ -238,7 +201,10 @@ var BetterAuth = class {
|
|
|
238
201
|
...oOptions.advanced?.ipAddress
|
|
239
202
|
}
|
|
240
203
|
},
|
|
241
|
-
database:
|
|
204
|
+
database: drizzleAdapter(oDb, {
|
|
205
|
+
provider: "pg",
|
|
206
|
+
schema: oSchema
|
|
207
|
+
})
|
|
242
208
|
});
|
|
243
209
|
this.oAuth = oAuth;
|
|
244
210
|
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
@@ -348,111 +314,43 @@ var Router = class {
|
|
|
348
314
|
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
349
315
|
}
|
|
350
316
|
};
|
|
351
|
-
// src/database/Prisma.ts
|
|
352
|
-
/** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
|
|
353
|
-
var Prisma = class {
|
|
354
|
-
/** Folder of a client and its files, in the project */
|
|
355
|
-
static dir(sName) {
|
|
356
|
-
return join(process.cwd(), ".nano-app", "prisma-clients", sName);
|
|
357
|
-
}
|
|
358
|
-
/** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
|
|
359
|
-
static generate(sName, sSchemaPath) {
|
|
360
|
-
const sDir = this.dir(sName);
|
|
361
|
-
const sSource = FileReader.getAbsolutePath(sSchemaPath);
|
|
362
|
-
const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
|
|
363
|
-
const sSourceFile = join(sDir, "source.json");
|
|
364
|
-
const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
|
|
365
|
-
if (oCurrent && oCurrent.schemaPath !== sSource) Logger.warn(`.nano-app/prisma-clients/${sName} has the client of ${oCurrent.schemaPath}: it is replaced by the one of ${sSource}.`);
|
|
366
|
-
if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
|
|
367
|
-
mkdirSync(sDir, { recursive: true });
|
|
368
|
-
const sSchema = readFileSync(sSource, "utf-8");
|
|
369
|
-
writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
|
|
370
|
-
this.run([
|
|
371
|
-
"generate",
|
|
372
|
-
"--schema",
|
|
373
|
-
join(sDir, "schema.prisma")
|
|
374
|
-
]);
|
|
375
|
-
writeFileSync(sSourceFile, JSON.stringify({
|
|
376
|
-
schemaPath: sSource,
|
|
377
|
-
hash: sHash
|
|
378
|
-
}));
|
|
379
|
-
Logger.info(`Prisma client "${sName}" generated`);
|
|
380
|
-
}
|
|
381
|
-
/** Applies the pending migrations an official plugin ships next to its schema */
|
|
382
|
-
static migrate(sName, sSchemaPath, sDbSchema) {
|
|
383
|
-
const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
|
|
384
|
-
if (!existsSync(sMigrations)) throw new NanoError(`The plugin "${sName}" ships no migrations: ${sMigrations}`);
|
|
385
|
-
const oUrl = new URL(process.env.DATABASE_URL ?? "");
|
|
386
|
-
oUrl.searchParams.set("schema", sDbSchema);
|
|
387
|
-
const sConfig = join(this.dir(sName), "prisma.config.mjs");
|
|
388
|
-
writeFileSync(sConfig, [
|
|
389
|
-
"export default {",
|
|
390
|
-
` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
|
|
391
|
-
` migrations: { path: ${JSON.stringify(sMigrations)} },`,
|
|
392
|
-
" datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
|
|
393
|
-
"};",
|
|
394
|
-
""
|
|
395
|
-
].join("\n"));
|
|
396
|
-
this.run([
|
|
397
|
-
"migrate",
|
|
398
|
-
"deploy",
|
|
399
|
-
"--config",
|
|
400
|
-
sConfig
|
|
401
|
-
], { NANO_APP_MIGRATION_URL: oUrl.toString() });
|
|
402
|
-
}
|
|
403
|
-
/** Runs a Prisma command of the consumer's `prisma` package */
|
|
404
|
-
static run(aArgs, oEnv = {}) {
|
|
405
|
-
const sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
|
|
406
|
-
execFileSync(process.execPath, [sCli, ...aArgs], {
|
|
407
|
-
env: {
|
|
408
|
-
...process.env,
|
|
409
|
-
...oEnv
|
|
410
|
-
},
|
|
411
|
-
stdio: "pipe"
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
317
|
// src/database/Database.ts
|
|
416
|
-
/**
|
|
318
|
+
/** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
|
|
417
319
|
var Database = class {
|
|
418
|
-
static
|
|
320
|
+
static oPool;
|
|
419
321
|
/** Mounted clients, by name */
|
|
420
322
|
static oClients = {};
|
|
421
|
-
/** Initializes the Database client */
|
|
422
|
-
static async init() {
|
|
423
|
-
const sUrl = process.env.DATABASE_URL;
|
|
424
|
-
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
425
|
-
const { PrismaPg } = await import("@prisma/adapter-pg");
|
|
426
|
-
this.oAdapter = new PrismaPg({ connectionString: sUrl });
|
|
427
|
-
}
|
|
428
323
|
/** Gets a client by name */
|
|
429
324
|
static get(sName) {
|
|
430
325
|
return this.oClients[sName];
|
|
431
326
|
}
|
|
432
|
-
/**
|
|
433
|
-
static async mount(sName, {
|
|
327
|
+
/** Mounts the Drizzle client of a schema: the app's tables outside `nano_*`, a plugin's only in `nano_<name>` */
|
|
328
|
+
static async mount(sName, { schema }) {
|
|
434
329
|
if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
330
|
+
const { defineRelations, is } = await import("drizzle-orm");
|
|
331
|
+
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
332
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
333
|
+
const sOwn = `nano_${sName}`;
|
|
334
|
+
const isApp = sName === "app";
|
|
335
|
+
for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
|
|
336
|
+
const { name, schema: sSchema = "public" } = getTableConfig(oTable);
|
|
337
|
+
if (isApp ? sSchema.startsWith("nano_") : sSchema !== sOwn) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${isApp ? "the nano_* schemas are reserved for plugins" : `a plugin keeps its tables in "${sOwn}"`}.`);
|
|
338
|
+
}
|
|
339
|
+
this.oPool ??= await this.pool();
|
|
340
|
+
const oClient = drizzle({
|
|
341
|
+
client: this.oPool,
|
|
342
|
+
relations: schema.relations ?? defineRelations(schema)
|
|
343
|
+
});
|
|
444
344
|
this.oClients[sName] = oClient;
|
|
445
345
|
Logger.info(`Database "${sName}" mounted`);
|
|
446
346
|
return oClient;
|
|
447
347
|
}
|
|
448
|
-
/**
|
|
449
|
-
static
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
return JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8")).name === OFFICIAL_PLUGINS;
|
|
348
|
+
/** The pool of `DATABASE_URL`, shared by every client */
|
|
349
|
+
static async pool() {
|
|
350
|
+
const sUrl = process.env.DATABASE_URL;
|
|
351
|
+
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
352
|
+
const { Pool } = await import("pg");
|
|
353
|
+
return new Pool({ connectionString: sUrl });
|
|
456
354
|
}
|
|
457
355
|
};
|
|
458
356
|
// src/routes/Plugin.ts
|
|
@@ -468,10 +366,10 @@ var Plugin = class {
|
|
|
468
366
|
/** Mounts its endpoints on express app */
|
|
469
367
|
async mount(oApp) {
|
|
470
368
|
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
471
|
-
const oDb = database
|
|
369
|
+
const oDb = database ? await Database.mount(name, database) : Database.get("app");
|
|
472
370
|
if (this.oAuthOptions) {
|
|
473
|
-
if (!
|
|
474
|
-
const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb);
|
|
371
|
+
if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
|
|
372
|
+
const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb, database.schema);
|
|
475
373
|
oApp.all(sPath, oHandler);
|
|
476
374
|
oApp.use(fnSession);
|
|
477
375
|
}
|
|
@@ -519,11 +417,7 @@ const INITIAL_CONFIG = {
|
|
|
519
417
|
/** Main plugin to mount */
|
|
520
418
|
mainPlugin: {
|
|
521
419
|
routesDir: "src/routes",
|
|
522
|
-
basePath: ""
|
|
523
|
-
database: {
|
|
524
|
-
enabled: false,
|
|
525
|
-
schemaPath: "src/prisma/schema.prisma"
|
|
526
|
-
}
|
|
420
|
+
basePath: ""
|
|
527
421
|
},
|
|
528
422
|
/** Plugins around the consumer routes */
|
|
529
423
|
plugins: []
|
|
@@ -555,26 +449,30 @@ function mergeConfig(oConfig = {}) {
|
|
|
555
449
|
};
|
|
556
450
|
}
|
|
557
451
|
// src/app/errors.ts
|
|
558
|
-
/**
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
status: 404,
|
|
562
|
-
message: "Not found"
|
|
563
|
-
},
|
|
564
|
-
P2002: {
|
|
452
|
+
/** Database errors the request caused, by their Postgres code or Drizzle's name; their own messages name tables and columns */
|
|
453
|
+
const DATABASE_ERRORS = {
|
|
454
|
+
"23505": {
|
|
565
455
|
status: 409,
|
|
566
456
|
message: "Conflict"
|
|
567
457
|
},
|
|
568
|
-
|
|
458
|
+
"23503": {
|
|
569
459
|
status: 409,
|
|
570
460
|
message: "Conflict"
|
|
571
461
|
},
|
|
572
|
-
|
|
462
|
+
"23502": {
|
|
463
|
+
status: 400,
|
|
464
|
+
message: "Invalid request"
|
|
465
|
+
},
|
|
466
|
+
"22P02": {
|
|
467
|
+
status: 400,
|
|
468
|
+
message: "Invalid request"
|
|
469
|
+
},
|
|
470
|
+
DrizzleError: {
|
|
573
471
|
status: 400,
|
|
574
472
|
message: "Invalid request"
|
|
575
473
|
}
|
|
576
474
|
};
|
|
577
|
-
/** Whether anything thrown is an object, read as a HandlerError or a
|
|
475
|
+
/** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
|
|
578
476
|
function isHandlerError(oError) {
|
|
579
477
|
return typeof oError === "object" && oError !== null;
|
|
580
478
|
}
|
|
@@ -588,7 +486,8 @@ function normalize(oError) {
|
|
|
588
486
|
}
|
|
589
487
|
};
|
|
590
488
|
const oThrown = isHandlerError(oError) ? oError : {};
|
|
591
|
-
const
|
|
489
|
+
const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
|
|
490
|
+
const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
|
|
592
491
|
const iStatus = status ?? 500;
|
|
593
492
|
return {
|
|
594
493
|
status: iStatus,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/backend",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
63
|
-
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"@prisma/client": {
|
|
61
|
+
"better-auth": {
|
|
66
62
|
"optional": true
|
|
67
63
|
},
|
|
68
|
-
"
|
|
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"
|