@nano-app/plugins 0.7.1 → 0.8.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 +8 -16
- package/build/crud/index.d.ts +4 -1
- package/build/crud/index.js +1 -1
- package/build/crud/routes/index.route.js +44 -22
- package/package.json +11 -6
- package/build/crud/Query.js +0 -62
package/README.md
CHANGED
|
@@ -20,19 +20,22 @@ createNanoApp({ plugins: [health()] });
|
|
|
20
20
|
| Plugin | Import | Endpoints | Options |
|
|
21
21
|
| -------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
|
22
22
|
| `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }` | `basePath` (default `"/health"`) |
|
|
23
|
-
| `crud` | `import crud from '@nano-app/plugins/crud'` | On the
|
|
23
|
+
| `crud` | `import crud from '@nano-app/plugins/crud'` | On the tables listed in `models`, by their name in the app's schema (any other answers 404): `GET` and `QUERY` on `/api/<model>{/<id>}` → a list `{ result }` or one record, with `QueryParams` as the body of `QUERY`: Drizzle's relational query on the table alone (`where`, `columns`, `orderBy`, `limit`, `offset`): a relation in `where` or `with` answers 400; `POST /api/<model>`, `PUT` and `DELETE` on `/api/<model>/<id>` → the record created, updated or deleted. Postgres casts `<id>` to the type of the `id` column. With Better Auth, each method asks for a permission on the table: `read` (`GET`, `QUERY`), `create`, `update`, `delete`, see [crud and related tables](#crud-and-related-tables) | `models` (the published ones), `schema` (the app's Drizzle schema, as in `mainPlugin.database`), `basePath` (default `"/api"`) |
|
|
24
24
|
| `rate-limit` | `import rateLimit from '@nano-app/plugins/rate-limit'` | None: limits every request of the routes and plugins mounted after it, per client IP (see [Client IP](../backend/README.md#client-ip)). Each request spends a token of the client's bucket, which holds `capacity` and refills `refillPerSecond` tokens per second; without one it answers `429` with `message` and `Retry-After` (seconds until the next token). List it first in `plugins`. The buckets live in the memory of each process | `capacity` (default `100`), `refillPerSecond` (default `1`), `message` |
|
|
25
25
|
|
|
26
26
|
What is not CRUD, such as creating a record with business logic, goes in a route of your own with its own URL: a route of yours on a `crud` URL takes every method of it.
|
|
27
27
|
|
|
28
|
+
### crud and related tables
|
|
29
|
+
|
|
30
|
+
A `crud` query never leaves its table: its client knows the app's tables but none of their relations, so a relation in `where` or `with` answers 400. Better Auth's tables (`user`, `account` with its password hashes, `session` with its tokens) are reached only when they are in `models`; never publish `account`, `session` or `verification`. What needs related tables goes in a route of your own.
|
|
31
|
+
|
|
28
32
|
### Layout
|
|
29
33
|
|
|
30
34
|
```
|
|
31
35
|
plugins/
|
|
32
36
|
└── <name>/
|
|
33
37
|
├── index.ts default-exports the plugin factory: the import path
|
|
34
|
-
├── schema.
|
|
35
|
-
├── migrations/ their migrations, applied at startup
|
|
38
|
+
├── schema.ts its tables, when it has any, in the Postgres schema nano_<name>
|
|
36
39
|
└── routes/ *.route.ts, mounted under the plugin's basePath
|
|
37
40
|
```
|
|
38
41
|
|
|
@@ -110,20 +113,9 @@ Use MIT or Apache-2.0 if you want the plugin listed from this repo later. Open a
|
|
|
110
113
|
|
|
111
114
|
### Database
|
|
112
115
|
|
|
113
|
-
Optional.
|
|
114
|
-
|
|
115
|
-
At startup nano-app generates the plugin's client in `.nano-app/prisma-clients/<name>` (again only when the schema changed) and checks that every model can be read.
|
|
116
|
-
|
|
117
|
-
- **A plugin of yours, or from another package:** its tables are applied with Prisma by whoever installs it, like the app's schema.
|
|
118
|
-
- **An official plugin:** nano-app also migrates it, so the consumer runs nothing. Its schema ships in `build/<name>/schema.prisma` with a `migrations/` folder next to it. At startup `prisma migrate deploy` applies the pending ones to the plugin's first `nano_*` schema, where its history (`_prisma_migrations`) lives apart from the app's. Each migration runs once, in a transaction, one instance at a time.
|
|
119
|
-
|
|
120
|
-
#### Migrations of an official plugin
|
|
121
|
-
|
|
122
|
-
- Every schema change ships its own `migrations/<timestamp>_<name>/migration.sql`, reviewed like code. A published migration is never edited.
|
|
123
|
-
- Migrations only add: tables, nullable columns or columns with a default, indexes. Dropping or renaming takes two releases: stop using it first, remove it later.
|
|
124
|
-
- The database user of the consumer needs to create tables in the plugin's `nano_*` schema.
|
|
116
|
+
Optional. Pass `database: { schema }` on the plugin config: the module of its Drizzle tables, with its `relations` when it has them. Its tables live in the Postgres schema `nano_<plugin name>`, which drizzle-kit creates when the module exports its `pgSchema`; a table elsewhere fails the startup.
|
|
125
117
|
|
|
126
|
-
|
|
118
|
+
Its tables are applied with drizzle-kit by whoever installs it, like the app's schema. Handlers and callbacks receive its client as `params.db`.
|
|
127
119
|
|
|
128
120
|
### What belongs in this repository
|
|
129
121
|
|
package/build/crud/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import { NanoObject } from "@nano-app/backend";
|
|
2
|
+
/** Automatic CRUD: the listed tables of the app's schema on `/api/<model>{/<id>}` */
|
|
2
3
|
declare const _default: (options?: {
|
|
3
4
|
basePath?: string;
|
|
4
5
|
models: string[];
|
|
6
|
+
schema: NanoObject;
|
|
5
7
|
} | undefined) => import("@nano-app/backend").PluginConfig<{
|
|
6
8
|
basePath?: string;
|
|
7
9
|
models: string[];
|
|
10
|
+
schema: NanoObject;
|
|
8
11
|
}>;
|
|
9
12
|
export { _default as default };
|
package/build/crud/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createPlugin } from "@nano-app/backend";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
// crud/index.ts
|
|
5
|
-
/** Automatic CRUD: the listed
|
|
5
|
+
/** Automatic CRUD: the listed tables of the app's schema on `/api/<model>{/<id>}` */
|
|
6
6
|
var crud_default = createPlugin((options) => ({
|
|
7
7
|
name: "crud",
|
|
8
8
|
basePath: options?.basePath ?? "/api",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import Query from "../Query.js";
|
|
2
1
|
import { NanoError, createRoute, isGranted } from "@nano-app/backend";
|
|
2
|
+
import { defineRelations, eq } from "drizzle-orm";
|
|
3
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
4
|
// crud/routes/index.route.ts
|
|
4
|
-
/** Permission of Better Auth that each method needs on the URL's
|
|
5
|
+
/** Permission of Better Auth that each method needs on the URL's table */
|
|
5
6
|
const ACTIONS = {
|
|
6
7
|
GET: "read",
|
|
7
8
|
QUERY: "read",
|
|
@@ -9,14 +10,33 @@ const ACTIONS = {
|
|
|
9
10
|
PUT: "update",
|
|
10
11
|
DELETE: "delete"
|
|
11
12
|
};
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
/** Records a list answers when the request sends no `limit` */
|
|
14
|
+
const DEFAULT_LIMIT = 100;
|
|
15
|
+
/** The first record of a query or a write: 404 without it */
|
|
16
|
+
function first(aRecords) {
|
|
17
|
+
if (!aRecords[0]) throw new NanoError("Not found", 404);
|
|
18
|
+
return { result: aRecords[0] };
|
|
19
|
+
}
|
|
20
|
+
/** A list of the table, or one record by its id, with Drizzle's relational query of the body on the table alone */
|
|
21
|
+
async function read(oReq, { db, schema, onBeforeResult }) {
|
|
22
|
+
const { model, id } = onBeforeResult ?? {};
|
|
23
|
+
const oBody = oReq.body ?? {};
|
|
24
|
+
const { relations: _oRelations, ...oTables } = schema;
|
|
25
|
+
const oNoRelations = defineRelations(oTables);
|
|
26
|
+
const oQuery = drizzle({
|
|
27
|
+
client: db.$client,
|
|
28
|
+
relations: oNoRelations
|
|
29
|
+
}).query[model];
|
|
30
|
+
if (id) return first(await oQuery.findMany({
|
|
31
|
+
...oBody,
|
|
32
|
+
where: { id },
|
|
33
|
+
limit: 1
|
|
34
|
+
}));
|
|
35
|
+
return { result: await oQuery.findMany({
|
|
36
|
+
...oBody,
|
|
37
|
+
where: oBody.where ?? {},
|
|
38
|
+
limit: oBody.limit ?? DEFAULT_LIMIT
|
|
39
|
+
}) };
|
|
20
40
|
}
|
|
21
41
|
/** `GET` reads without parameters, `QUERY` with `QueryParams` as its body */
|
|
22
42
|
var index_route_default = createRoute({
|
|
@@ -24,23 +44,25 @@ var index_route_default = createRoute({
|
|
|
24
44
|
callbacks: { onBefore: async (oReq, sMethod, oParams) => {
|
|
25
45
|
const sModel = String(oReq.params.model);
|
|
26
46
|
const { id } = oReq.params;
|
|
27
|
-
|
|
47
|
+
const oTable = oParams.schema?.[sModel];
|
|
48
|
+
if (!oParams.models.includes(sModel) || !oTable) throw new NanoError("Not found", 404);
|
|
28
49
|
if (!await isGranted(oParams, { [sModel]: [ACTIONS[sMethod]] })) throw new NanoError("Forbidden", 403);
|
|
29
|
-
const oModel = oParams.db[sModel];
|
|
30
|
-
if (!id) return { model: oModel };
|
|
31
|
-
const sId = String(id);
|
|
32
50
|
return {
|
|
33
|
-
model:
|
|
34
|
-
|
|
51
|
+
model: sModel,
|
|
52
|
+
table: oTable,
|
|
53
|
+
id
|
|
35
54
|
};
|
|
36
55
|
} },
|
|
37
56
|
GET: read,
|
|
38
57
|
QUERY: read,
|
|
39
|
-
POST: async (oReq, { onBeforeResult }) => (
|
|
40
|
-
PUT: async (oReq, { onBeforeResult }) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
DELETE: async (_oReq, { onBeforeResult }) =>
|
|
58
|
+
POST: async (oReq, { db, onBeforeResult }) => first(await db.insert(onBeforeResult?.table).values(oReq.body).returning()),
|
|
59
|
+
PUT: async (oReq, { db, onBeforeResult }) => {
|
|
60
|
+
const { table, id } = onBeforeResult ?? {};
|
|
61
|
+
return first(await db.update(table).set(oReq.body).where(eq(table.id, id)).returning());
|
|
62
|
+
},
|
|
63
|
+
DELETE: async (_oReq, { db, onBeforeResult }) => {
|
|
64
|
+
const { table, id } = onBeforeResult ?? {};
|
|
65
|
+
return first(await db.delete(table).where(eq(table.id, id)).returning());
|
|
66
|
+
}
|
|
45
67
|
});
|
|
46
68
|
export { index_route_default as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/plugins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Official plugins for @nano-app/backend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsdown",
|
|
18
|
-
"test": "tsx --env-file-if-exists=../.env --test tests/*.test.ts",
|
|
18
|
+
"test": "tsx --env-file-if-exists=../.env --test --test-concurrency=1 tests/*.test.ts",
|
|
19
19
|
"prepublishOnly": "pnpm run build"
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|
|
@@ -37,21 +37,26 @@
|
|
|
37
37
|
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@nano-app/backend": ">=0.15.0"
|
|
40
|
+
"@nano-app/backend": ">=0.15.0",
|
|
41
|
+
"drizzle-orm": "^1.0.0-rc.5"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@happy-dom/global-registrator": "^21.0.4",
|
|
44
45
|
"@nano-app/backend": "workspace:*",
|
|
45
|
-
"@prisma/adapter-pg": "^7.10.1",
|
|
46
|
-
"@prisma/client": "^7.10.1",
|
|
47
46
|
"@types/node": "^26.6.6",
|
|
48
47
|
"@types/pg": "^8.23.1",
|
|
48
|
+
"drizzle-kit": "^1.0.0-rc.5",
|
|
49
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
49
50
|
"glob": "^14.0.4",
|
|
50
51
|
"next": "^16.4.5",
|
|
51
52
|
"pg": "^8.23.1",
|
|
52
|
-
"prisma": "^7.10.1",
|
|
53
53
|
"tsdown": "^0.23.5",
|
|
54
54
|
"tsx": "^4.23.15",
|
|
55
55
|
"typescript": "^7.0.2"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"drizzle-orm": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
56
61
|
}
|
|
57
62
|
}
|
package/build/crud/Query.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
// crud/Query.ts
|
|
2
|
-
/** Records a list answers when the request sends no `top` */
|
|
3
|
-
const DEFAULT_TOP = 100;
|
|
4
|
-
/** Reads a model of the app's Prisma client with core's `QueryParams` */
|
|
5
|
-
var Query = class Query {
|
|
6
|
-
/** A list of the model with its total, or one record by its `where` */
|
|
7
|
-
static async read(oModel, oQuery, oWhere) {
|
|
8
|
-
const oArgs = Query.toPrisma(oQuery);
|
|
9
|
-
if (oWhere) {
|
|
10
|
-
const { select, include } = oArgs;
|
|
11
|
-
return { result: await oModel.findUniqueOrThrow({
|
|
12
|
-
where: oWhere,
|
|
13
|
-
select,
|
|
14
|
-
include
|
|
15
|
-
}) };
|
|
16
|
-
}
|
|
17
|
-
const [aRecords, iCount] = await Promise.all([oModel.findMany(oArgs), oModel.count({ where: oArgs.where })]);
|
|
18
|
-
return {
|
|
19
|
-
result: aRecords,
|
|
20
|
-
count: iCount
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
/** `filter` → `where`, `orderby` → `orderBy`, `top`/`skip` → `take`/`skip`, `select` and `expand` → `select` or `include` */
|
|
24
|
-
static toPrisma(oQuery) {
|
|
25
|
-
const { filter, orderby, top, skip, select, expand } = oQuery;
|
|
26
|
-
const oInclude = expand ? Query.include(Query.list(expand)) : void 0;
|
|
27
|
-
const oSelect = select ? Query.fields(Query.list(select), oInclude) : void 0;
|
|
28
|
-
return {
|
|
29
|
-
where: filter,
|
|
30
|
-
orderBy: orderby && Object.entries(orderby).map(([sPath, sDirection]) => Query.nest(sPath.split("."), sDirection)),
|
|
31
|
-
take: top ?? DEFAULT_TOP,
|
|
32
|
-
skip,
|
|
33
|
-
select: oSelect,
|
|
34
|
-
include: oSelect ? void 0 : oInclude
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
/** `['user', 'name']`, `'asc'` → `{ user: { name: 'asc' } }` */
|
|
38
|
-
static nest(aPath, sValue) {
|
|
39
|
-
const [sField, ...aRest] = aPath;
|
|
40
|
-
return { [sField]: aRest.length ? Query.nest(aRest, sValue) : sValue };
|
|
41
|
-
}
|
|
42
|
-
/** `['user', 'posts.comments']` → `{ user: true, posts: { include: { comments: true } } }` */
|
|
43
|
-
static include(aPaths) {
|
|
44
|
-
const aRelations = [...new Set(aPaths.map((sPath) => sPath.split(".")[0]))];
|
|
45
|
-
return Object.fromEntries(aRelations.map((sRelation) => {
|
|
46
|
-
const aNested = aPaths.filter((sPath) => sPath.startsWith(`${sRelation}.`)).map((sPath) => sPath.slice(sRelation.length + 1));
|
|
47
|
-
return [sRelation, aNested.length ? { include: Query.include(aNested) } : true];
|
|
48
|
-
}));
|
|
49
|
-
}
|
|
50
|
-
/** `['id', 'name']` → `{ id: true, name: true }`, plus the relations to load */
|
|
51
|
-
static fields(aFields, oInclude) {
|
|
52
|
-
return {
|
|
53
|
-
...Object.fromEntries(aFields.map((sField) => [sField, true])),
|
|
54
|
-
...oInclude
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
/** `"a, b"` → `['a', 'b']` */
|
|
58
|
-
static list(sList) {
|
|
59
|
-
return sList.split(",").map((sItem) => sItem.trim());
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
export { Query as default };
|