mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
274 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89871a8ef7 | |||
| 7f0f6bec08 | |||
| 8e1ad98964 | |||
| a29e824ecc | |||
| b436758fe6 | |||
| ee7dcd44f4 | |||
| f2e5815e24 | |||
| 335fe42a22 | |||
| a01b99be27 | |||
| a4e46a2768 | |||
| f494735a79 | |||
| 400db84dd5 | |||
| 964bacd824 | |||
| 7994eabe7d | |||
| dec382b49d | |||
| 93cee4f8aa | |||
| e99abb09b6 | |||
| a5ab102b34 | |||
| 1ca6c5b43d | |||
| 64658184ac | |||
| 61ad4a3109 | |||
| e12ed1e4b3 | |||
| e103d3ac33 | |||
| e2217c411a | |||
| 3e6d381239 | |||
| 0823b182f6 | |||
| bd362607ae | |||
| 61ac31eba9 | |||
| 21a8f56c48 | |||
| fb76f8d789 | |||
| 4bd201d454 | |||
| b412da836c | |||
| 425465becd | |||
| 3b487ade2a | |||
| 7d3c76d7c1 | |||
| fd297c6c50 | |||
| 149a6d768c | |||
| 24e69eec90 | |||
| 9c38c8f2ad | |||
| f0bdb3a75e | |||
| c4e505582b | |||
| cc938db4b8 | |||
| 68277a68d5 | |||
| 8e72b5b615 | |||
| 07422f30ba | |||
| d84731d89e | |||
| 4afea14893 | |||
| 2c6a4d2bed | |||
| f466dd166d | |||
| 6d8f86b2c9 | |||
| c8fa704e32 | |||
| 61d06973a4 | |||
| 7a6321f4ce | |||
| 4dde67ca21 | |||
| f9c612e0fa | |||
| d7e772efd9 | |||
| be39d1c374 | |||
| 1e5c0dbc22 | |||
| d79da9d204 | |||
| 09d35d3cc0 | |||
| f7e6928dba | |||
| 3fa682bfe1 | |||
| 97b0ff24c8 | |||
| bb964dc835 | |||
| 5840ec6b89 | |||
| f29641c702 | |||
| 2e3ee65aa7 | |||
| 11a6f5c9b9 | |||
| 324d641410 | |||
| 02e7e1ca95 | |||
| 78d4f928ba | |||
| 0a5cc2ad98 | |||
| e14d528386 | |||
| 15003b004b | |||
| 9a4c2bd530 | |||
| ab61a1392c | |||
| 53d55448e6 | |||
| 46cf310ad6 | |||
| 8418231c43 | |||
| 4b3493a6f5 | |||
| f432473ed9 | |||
| 8f0cef1f37 | |||
| a8e78f984d | |||
| babeaae8d1 | |||
| 45d44f8177 | |||
| db476080ad | |||
| 158818520b | |||
| 4755288fed | |||
| 10db267164 | |||
| dfe7d55057 | |||
| c2b3316fcb | |||
| 86ba055f5e | |||
| 9d6ccda20d | |||
| 39c419c320 | |||
| 2d6d83ccb2 | |||
| ba1eb5fe08 | |||
| 123389786d | |||
| b65bb3b826 | |||
| 5b40d5eaf4 | |||
| 89b1fd3909 | |||
| aa7eac258b | |||
| 392a06c486 | |||
| 1beac5043b | |||
| 475488c910 | |||
| 9ddacb7ae5 | |||
| 3d1d52cbf8 | |||
| acc504cdc9 | |||
| d98d3351c0 | |||
| 3e606cb159 | |||
| cc76964d03 | |||
| b0c5f6307a | |||
| b3019d7333 | |||
| 2265451d4b | |||
| ba936604a3 | |||
| 7dd42fbff3 | |||
| f64e5dac03 | |||
| 24542f30cb | |||
| 51715158c2 | |||
| 42f935acfd | |||
| a4ce93bc7e | |||
| fc08108ee0 | |||
| 7cc437e816 | |||
| a20b6b64a9 | |||
| 664aed35f9 | |||
| fb2dff956b | |||
| db10188945 | |||
| 145b47e942 | |||
| ebd4565166 | |||
| ad61770ef4 | |||
| f4dc26198b | |||
| 89b29256cf | |||
| 7ddcfc89b4 | |||
| baab70f9da | |||
| a6fd9f0d96 | |||
| 9422cc5bb8 | |||
| c3d0f01390 | |||
| c19d3b2d75 | |||
| a723d6f618 | |||
| 1625a0c7c0 | |||
| ac3c7316ff | |||
| b61634e261 | |||
| d4f647c0db | |||
| ad3fceaf60 | |||
| 2a015ba0a1 | |||
| 8a6d8329f3 | |||
| 69ea5a00ee | |||
| c6cbd36231 | |||
| 371184d232 | |||
| 8226b644ae | |||
| 26a5fd8b34 | |||
| 37a65bcaf6 | |||
| 5343d0bd9d | |||
| 92c3457099 | |||
| 6c491653db | |||
| ec1be3c107 | |||
| 85c9020055 | |||
| b490c01226 | |||
| f47218708a | |||
| aa4aca1a90 | |||
| 6c9707d12c | |||
| 438e36f185 | |||
| 6625c9bc48 | |||
| f47d0b1761 | |||
| a4274423a1 | |||
| a81aa875a8 | |||
| 7b0a41b297 | |||
| 1ea6446fab | |||
| 4ee0f74cdb | |||
| 4e923bff69 | |||
| c7bd0a636b | |||
| a9b2c613e1 | |||
| 6855e6f7e9 | |||
| b1a32f3705 | |||
| d8671355a6 | |||
| bd4bc14282 | |||
| 5823c2d245 | |||
| c732566f63 | |||
| 0d945ab45b | |||
| c06ba061f0 | |||
| 3bf92a8c65 | |||
| 87e07570d4 | |||
| c7d983942f | |||
| bb756548a6 | |||
| e94e8d8bd1 | |||
| 1d5f14fae0 | |||
| a8c20d3675 | |||
| 475563b5e1 | |||
| f0d502133e | |||
| 0500d4fc8e | |||
| 5bccc910a3 | |||
| da3f3d98d0 | |||
| c8745b3464 | |||
| eabd57cb4e | |||
| d182640981 | |||
| 138a3579cb | |||
| 99df7f1402 | |||
| 47f48be514 | |||
| 5c7bfeab8f | |||
| 7f337e25bb | |||
| fcab042e88 | |||
| aa86d54553 | |||
| 6026613d29 | |||
| 7d3d1e811f | |||
| 9f2c20ccee | |||
| 064bbba8aa | |||
| d1aa05a9bb | |||
| ab46611839 | |||
| 24226382e8 | |||
| d04c4a6191 | |||
| 06125f1afe | |||
| 8ef11aa382 | |||
| 76da14294c | |||
| c1e92e503b | |||
| 70e42a02d7 | |||
| a17fd2df67 | |||
| 0648f71a9e | |||
| 131b7dd52e | |||
| 3a79ce2cf8 | |||
| deddf00c38 | |||
| a7e3ce878a | |||
| d7ee13011f | |||
| 1d1ebff64d | |||
| ac2ae2657e | |||
| 8e987d58f2 | |||
| 386c0d3ff0 | |||
| 602235b372 | |||
| c4138ef823 | |||
| 1631bbb754 | |||
| 8c91dff94d | |||
| 50c5adce0c | |||
| 9d9aa7b7a5 | |||
| 43ec075a32 | |||
| 29ae6c6f9d | |||
| bd86f8ef91 | |||
| 25027429df | |||
| 35959aaf6d | |||
| 56fb779f99 | |||
| 134fbd6d34 | |||
| d2c75b1605 | |||
| a474e3fe3a | |||
| d81b3acb94 | |||
| 2ca66f4fc9 | |||
| a8bbb6e760 | |||
| 32459a1562 | |||
| 2395d7fe97 | |||
| b7ec4982dc | |||
| 10d606f8e9 | |||
| 5cca911e9d | |||
| 13a88be8d1 | |||
| a5264e22ee | |||
| 290498de6e | |||
| 847b08b505 | |||
| 86a7bee3d9 | |||
| 3932396084 | |||
| 94cc4042d3 | |||
| 154703f873 | |||
| 71dbfc5469 | |||
| e5924b33e5 | |||
| c25abd4a48 | |||
| 7c58f20cca | |||
| ef18d8d955 | |||
| c7c6aa7df8 | |||
| 060af0a1e3 | |||
| 44a427de67 | |||
| aa75355a69 | |||
| 110c7681b7 | |||
| ba517feab5 | |||
| 7e990feb99 | |||
| 77a6b6e7f5 | |||
| a3348122e6 | |||
| 3757157a06 | |||
| 6c2f7b32e5 | |||
| 067213ff60 | |||
| b3e02394ac |
+6
-1
@@ -14,14 +14,19 @@ packages/media/.env
|
|||||||
**/*/.env
|
**/*/.env
|
||||||
**/*/.dev.vars
|
**/*/.dev.vars
|
||||||
**/*/.wrangler
|
**/*/.wrangler
|
||||||
|
**/*/*.tgz
|
||||||
**/*/vite.config.ts.timestamp*
|
**/*/vite.config.ts.timestamp*
|
||||||
.history
|
.history
|
||||||
**/*/.db/*
|
**/*/.db/*
|
||||||
|
**/*/.configs/*
|
||||||
|
**/*/.template/*
|
||||||
**/*/*.db
|
**/*/*.db
|
||||||
**/*/*.db-shm
|
**/*/*.db-shm
|
||||||
**/*/*.db-wal
|
**/*/*.db-wal
|
||||||
|
**/*/.tmp
|
||||||
.npmrc
|
.npmrc
|
||||||
/.verdaccio
|
/.verdaccio
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.git_old
|
.git_old
|
||||||
|
docker/tmp
|
||||||
+1
-1
@@ -6,7 +6,7 @@ FSL-1.1-MIT
|
|||||||
|
|
||||||
## Notice
|
## Notice
|
||||||
|
|
||||||
Copyright 2024 Webintex GmbH
|
Copyright 2025 Dennis Senn
|
||||||
|
|
||||||
## Terms and Conditions
|
## Terms and Conditions
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||

|
[](https://npmjs.org/package/bknd)
|
||||||
|
[](https://www.npmjs.com/package/bknd)
|
||||||
|
|
||||||
bknd simplifies app development by providing fully functional backend for data management,
|

|
||||||
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
|
||||||
be deployed nearly anywhere, including running inside your framework of choice. No more
|
<p align="center" width="100%">
|
||||||
deploying multiple separate services!
|
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
|
||||||
|
<strong>⭐ Live Demo</strong>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
|
||||||
|
|
||||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
|
|
||||||
@@ -11,33 +17,116 @@ deploying multiple separate services!
|
|||||||
> Please keep in mind that **bknd** is still under active development
|
> Please keep in mind that **bknd** is still under active development
|
||||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||||
|
|
||||||
## ✨ Features
|
## Size
|
||||||
**📊 Data**: Define, query, and control your data with ease.
|

|
||||||
- Define entities with fields and relationships, synced directly to your database.
|

|
||||||
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`.
|

|
||||||
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`.
|

|
||||||
- Advanced querying with the **Repository**: filtering, sorting, pagination, and relational data handling.
|
|
||||||
- Seamlessly manage data with mutators and a robust event system.
|
|
||||||
- Extend database functionality with batching, introspection, and support for multiple SQL dialects.
|
|
||||||
|
|
||||||
**🔐 Auth**: Easily implement reliable authentication strategies.
|
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
|
||||||
- Built-in `user` entity with customizable fields.
|
|
||||||
- Supports multiple authentication strategies:
|
|
||||||
- Email/password (with hashed storage).
|
|
||||||
- OAuth/OIDC (Google, GitHub, and more).
|
|
||||||
- Secure JWT generation and session management.
|
|
||||||
|
|
||||||
**🖼️ Media**: Effortlessly manage and serve all your media files.
|
## Motivation
|
||||||
- Upload files with ease.
|
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
|
||||||
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
|
|
||||||
|
|
||||||
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
|
**The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
|
||||||
- Create and run workflows with trigger-based automation:
|
|
||||||
- Manual triggers or events from data, auth, media, or server actions.
|
## Features
|
||||||
- HTTP triggers for external integrations.
|
* ⚡ Instant backend with full REST API:
|
||||||
- Define tasks in sequence, parallel, or loops, with conditional execution.
|
* **Data**: Define, query, and control your data with ease.
|
||||||
- Use reusable sub-workflows to organize complex processes.
|
* **Auth**: Easily implement reliable authentication strategies.
|
||||||
- Leverage OpenAPI specifications for API-based tasks.
|
* **Media**: Effortlessly manage and serve all your media files.
|
||||||
|
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
|
||||||
|
* 🌐 Built on Web Standards for maximum compatibility
|
||||||
|
* 🏃♂️ Multiple run modes
|
||||||
|
* standalone using the CLI
|
||||||
|
* using a JavaScript runtime (Node, Bun, workerd)
|
||||||
|
* using a React framework (Astro, Remix, Next.js)
|
||||||
|
* 📦 Official API and React SDK with type-safety
|
||||||
|
* ⚛️ React elements for auto-configured authentication and media components
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
The package is mainly split into 4 parts, each serving a specific purpose:
|
||||||
|
|
||||||
|
| Import | Purpose |
|
||||||
|
|-----------------------------|------------------------------------------------------|
|
||||||
|
| `bknd`<br/>`bknd/adapter/*` | Backend including APIs and adapters |
|
||||||
|
| `bknd/ui` | Admin UI components for react frameworks |
|
||||||
|
| `bknd/client` | TypeScript SDK and React hooks for the API endpoints |
|
||||||
|
| `bknd/elements` | React components for authentication and media |
|
||||||
|
|
||||||
|
|
||||||
|
### The backend (`bknd`)
|
||||||
|
Serve the backend as an API for any JS runtime or framework. The latter is especially handy, as it allows you to deploy your frontend and backend bundled together. Furthermore it allows adding additional logic in a way you're already familar with. Just add another route and you're good to go.
|
||||||
|
|
||||||
|
Here is an example of serving the API using node:
|
||||||
|
```js index.js
|
||||||
|
import { serve } from "bknd/adapter/node"
|
||||||
|
serve();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integrated admin UI (`bknd/ui`)
|
||||||
|
The admin UI allows to manage your data including full configuration of your backend using a graphical user interface. Using `vite`, your admin route looks like this:
|
||||||
|
```tsx
|
||||||
|
import { Admin } from "bknd/ui"
|
||||||
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <Admin />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the REST API or TypeScript SDK (`bknd/client`)
|
||||||
|
If you're not using a JavaScript environment, you can still access any endpoint using the REST API:
|
||||||
|
```bash
|
||||||
|
curl -XGET <your-endpoint>/api/data/<entity>
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, ... },
|
||||||
|
{ "id": 2, ... }
|
||||||
|
],
|
||||||
|
"meta": { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a JavaScript environment, you can use the TypeScript SDK with type-safety. The above example would look like this:
|
||||||
|
```ts
|
||||||
|
import { Api } from "bknd/client";
|
||||||
|
|
||||||
|
const api = new Api({ host: "<endpoint>" });
|
||||||
|
const { data } = await api.data.readMany("<entity>");
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're using React, there are 2 hooks exposed (`useApi`, `useEntity`), as well as an `swr` wrapper around each (`useApiQuery`, `useEntityQuery`). The `swr` wrapped hooks automatically handled query invalidation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useEntityQuery } from "bknd/client";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { data } = useEntityQuery("todos");
|
||||||
|
return <ul>
|
||||||
|
{data?.map(todo => (
|
||||||
|
<li key={todo.id}>{todo.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React elements (`bknd/elements`)
|
||||||
|
You don't have to figure out API details to include media uploads to your app. For an user avatar upload, this is all you need:
|
||||||
|
```tsx
|
||||||
|
import { Media } from "bknd/elements"
|
||||||
|
import "bknd/dist/main.css"
|
||||||
|
|
||||||
|
export function UserAvatar() {
|
||||||
|
return <Media.Dropzone
|
||||||
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
|
maxItems={1}
|
||||||
|
overwrite
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The import path also exports components for login and registration forms which are automatically pointed to the `bknd` defaults.
|
||||||
|
|
||||||
|
|
||||||
## 🚀 Quick start
|
## 🚀 Quick start
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { type TSchema, Type, stripMark } from "../src/core/utils";
|
|
||||||
import { Module } from "../src/modules/Module";
|
|
||||||
|
|
||||||
function createModule<Schema extends TSchema>(schema: Schema) {
|
|
||||||
class TestModule extends Module<typeof schema> {
|
|
||||||
getSchema() {
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
toJSON() {
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
useForceParse() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return TestModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Module", async () => {
|
|
||||||
test("basic", async () => {});
|
|
||||||
|
|
||||||
test("listener", async () => {
|
|
||||||
let result: any;
|
|
||||||
|
|
||||||
const module = createModule(Type.Object({ a: Type.String() }));
|
|
||||||
const m = new module({ a: "test" });
|
|
||||||
|
|
||||||
await m.schema().set({ a: "test2" });
|
|
||||||
m.setListener(async (c) => {
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
result = stripMark(c);
|
|
||||||
});
|
|
||||||
await m.schema().set({ a: "test3" });
|
|
||||||
expect(result).toEqual({ a: "test3" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { mark, stripMark } from "../src/core/utils";
|
|
||||||
import { ModuleManager } from "../src/modules/ModuleManager";
|
|
||||||
import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations";
|
|
||||||
import { getDummyConnection } from "./helper";
|
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
|
||||||
test("s1: no config, no build", async () => {
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
|
||||||
|
|
||||||
const mm = new ModuleManager(dummyConnection);
|
|
||||||
|
|
||||||
// that is because no module is built
|
|
||||||
expect(mm.toJSON()).toEqual({ version: 0 } as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("s2: no config, build", async () => {
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
|
||||||
|
|
||||||
const mm = new ModuleManager(dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
|
|
||||||
expect(mm.version()).toBe(CURRENT_VERSION);
|
|
||||||
expect(mm.built()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("s3: config given, table exists, version matches", async () => {
|
|
||||||
const c = getDummyConnection();
|
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
const version = mm.version();
|
|
||||||
const json = mm.configs();
|
|
||||||
//const { version, ...json } = mm.toJSON() as any;
|
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
|
||||||
const db = c2.dummyConnection.kysely;
|
|
||||||
await migrateSchema(CURRENT_VERSION, { db });
|
|
||||||
await db
|
|
||||||
.updateTable(TABLE_NAME)
|
|
||||||
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
|
|
||||||
await mm2.build();
|
|
||||||
|
|
||||||
expect(json).toEqual(mm2.configs());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("s4: config given, table exists, version outdated, migrate", async () => {
|
|
||||||
const c = getDummyConnection();
|
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
const version = mm.version();
|
|
||||||
const json = mm.configs();
|
|
||||||
//const { version, ...json } = mm.toJSON() as any;
|
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
|
||||||
const db = c2.dummyConnection.kysely;
|
|
||||||
console.log("here2");
|
|
||||||
await migrateSchema(CURRENT_VERSION, { db });
|
|
||||||
await db
|
|
||||||
.updateTable(TABLE_NAME)
|
|
||||||
.set({ json: JSON.stringify(json), version: CURRENT_VERSION - 1 })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const mm2 = new ModuleManager(c2.dummyConnection, {
|
|
||||||
initial: { version: version - 1, ...json }
|
|
||||||
});
|
|
||||||
console.log("here3");
|
|
||||||
await mm2.build();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("s5: config given, table exists, version mismatch", async () => {
|
|
||||||
const c = getDummyConnection();
|
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
const version = mm.version();
|
|
||||||
const json = mm.configs();
|
|
||||||
//const { version, ...json } = mm.toJSON() as any;
|
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
|
||||||
const db = c2.dummyConnection.kysely;
|
|
||||||
await migrateSchema(CURRENT_VERSION, { db });
|
|
||||||
await db
|
|
||||||
.updateTable(TABLE_NAME)
|
|
||||||
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const mm2 = new ModuleManager(c2.dummyConnection, {
|
|
||||||
initial: { version: version - 1, ...json }
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("s6: no config given, table exists, fetch", async () => {
|
|
||||||
const c = getDummyConnection();
|
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
const json = mm.configs();
|
|
||||||
//const { version, ...json } = mm.toJSON() as any;
|
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
|
||||||
const db = c2.dummyConnection.kysely;
|
|
||||||
await migrateSchema(CURRENT_VERSION, { db });
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
...json,
|
|
||||||
data: {
|
|
||||||
...json.data,
|
|
||||||
basepath: "/api/data2"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await db
|
|
||||||
.updateTable(TABLE_NAME)
|
|
||||||
.set({ json: JSON.stringify(config), version: CURRENT_VERSION })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// run without config given
|
|
||||||
const mm2 = new ModuleManager(c2.dummyConnection);
|
|
||||||
await mm2.build();
|
|
||||||
|
|
||||||
expect(mm2.configs().data.basepath).toBe("/api/data2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("blank app, modify config", async () => {
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
|
||||||
|
|
||||||
const mm = new ModuleManager(dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
const configs = stripMark(mm.configs());
|
|
||||||
|
|
||||||
expect(mm.configs().server.admin.color_scheme).toBe("light");
|
|
||||||
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
|
|
||||||
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
|
|
||||||
await mm.save();
|
|
||||||
|
|
||||||
expect(mm.configs().server.admin.color_scheme).toBe("dark");
|
|
||||||
expect(stripMark(mm.configs())).toEqual({
|
|
||||||
...configs,
|
|
||||||
server: {
|
|
||||||
...configs.server,
|
|
||||||
admin: {
|
|
||||||
...configs.server.admin,
|
|
||||||
color_scheme: "dark"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// @todo: check what happens here
|
|
||||||
/*test("blank app, modify deep config", async () => {
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
|
||||||
|
|
||||||
const mm = new ModuleManager(dummyConnection);
|
|
||||||
await mm.build();
|
|
||||||
|
|
||||||
/!* await mm
|
|
||||||
.get("data")
|
|
||||||
.schema()
|
|
||||||
.patch("entities.test", {
|
|
||||||
fields: {
|
|
||||||
content: {
|
|
||||||
type: "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await mm.build();
|
|
||||||
|
|
||||||
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
mm.get("data").schema().patch("desc", "entities.users.config.sort_dir")
|
|
||||||
).rejects.toThrow();
|
|
||||||
await mm.build();*!/
|
|
||||||
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
|
|
||||||
console.log("here", mm.configs());
|
|
||||||
await mm
|
|
||||||
.get("data")
|
|
||||||
.schema()
|
|
||||||
.patch("entities.users", { config: { sort_dir: "desc" } });
|
|
||||||
await mm.build();
|
|
||||||
expect(mm.toJSON());
|
|
||||||
|
|
||||||
//console.log(_jsonp(mm.toJSON().data));
|
|
||||||
/!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text");
|
|
||||||
expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/
|
|
||||||
});*/
|
|
||||||
|
|
||||||
/*test("accessing modules", async () => {
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
|
||||||
|
|
||||||
const mm = new ModuleManager(dummyConnection);
|
|
||||||
|
|
||||||
//mm.get("auth").mutate().set({});
|
|
||||||
});*/
|
|
||||||
});
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tmp/*
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { sign } from "hono/jwt";
|
||||||
|
import { Api } from "../../src/Api";
|
||||||
|
|
||||||
|
describe("Api", async () => {
|
||||||
|
it("should construct without options", () => {
|
||||||
|
const api = new Api();
|
||||||
|
expect(api.baseUrl).toBe("http://localhost");
|
||||||
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore force verify if no claims given", () => {
|
||||||
|
const api = new Api({ verified: true });
|
||||||
|
expect(api.baseUrl).toBe("http://localhost");
|
||||||
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should construct from request (token)", async () => {
|
||||||
|
const token = await sign({ foo: "bar" }, "1234");
|
||||||
|
const request = new Request("http://example.com/test", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const api = new Api({ request });
|
||||||
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
|
||||||
|
const params = api.getParams();
|
||||||
|
expect(params.token).toBe(token);
|
||||||
|
expect(params.token_transport).toBe("header");
|
||||||
|
expect(params.host).toBe("http://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should construct from request (cookie)", async () => {
|
||||||
|
const token = await sign({ foo: "bar" }, "1234");
|
||||||
|
const request = new Request("http://example.com/test", {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth=${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const api = new Api({ request });
|
||||||
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
|
||||||
|
const params = api.getParams();
|
||||||
|
console.log(params);
|
||||||
|
expect(params.token).toBe(token);
|
||||||
|
expect(params.token_transport).toBe("cookie");
|
||||||
|
expect(params.host).toBe("http://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should construct from token", async () => {
|
||||||
|
const token = await sign({ foo: "bar" }, "1234");
|
||||||
|
const api = new Api({ token });
|
||||||
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
|
||||||
|
const params = api.getParams();
|
||||||
|
expect(params.token).toBe(token);
|
||||||
|
expect(params.token_transport).toBe("header");
|
||||||
|
expect(params.host).toBe("http://localhost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer host when request is given", async () => {
|
||||||
|
const request = new Request("http://example.com/test");
|
||||||
|
const api = new Api({ request, host: "http://another.com" });
|
||||||
|
|
||||||
|
const params = api.getParams();
|
||||||
|
expect(params.token).toBeUndefined();
|
||||||
|
expect(params.token_transport).toBe("header");
|
||||||
|
expect(params.host).toBe("http://another.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { Guard } from "../../src/auth";
|
||||||
|
import { parse } from "../../src/core/utils";
|
||||||
|
import { DataApi } from "../../src/data/api/DataApi";
|
||||||
|
import { DataController } from "../../src/data/api/DataController";
|
||||||
|
import { dataConfigSchema } from "../../src/data/data-schema";
|
||||||
|
import * as proto from "../../src/data/prototype";
|
||||||
|
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
const dataConfig = parse(dataConfigSchema, {});
|
||||||
|
describe("DataApi", () => {
|
||||||
|
it("should switch to post for long url reads", async () => {
|
||||||
|
const api = new DataApi();
|
||||||
|
|
||||||
|
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
|
||||||
|
expect(get.request.method).toBe("GET");
|
||||||
|
expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
|
||||||
|
|
||||||
|
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
|
||||||
|
expect(post.request.method).toBe("POST");
|
||||||
|
expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns result", async () => {
|
||||||
|
const schema = proto.em({
|
||||||
|
posts: proto.entity("posts", { title: proto.text() })
|
||||||
|
});
|
||||||
|
const em = schemaToEm(schema);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }];
|
||||||
|
await em.mutator("posts").insertMany(payload);
|
||||||
|
|
||||||
|
const ctx: any = { em, guard: new Guard() };
|
||||||
|
const controller = new DataController(ctx, dataConfig);
|
||||||
|
const app = controller.getController();
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = (await app.request("/entity/posts")) as Response;
|
||||||
|
const { data } = await res.json();
|
||||||
|
expect(data.length).toEqual(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new DataApi({ basepath: "/", queryLengthLimit: 50 });
|
||||||
|
// @ts-ignore protected
|
||||||
|
api.fetcher = app.request as typeof fetch;
|
||||||
|
{
|
||||||
|
const req = api.readMany("posts", { select: ["title"] });
|
||||||
|
expect(req.request.method).toBe("GET");
|
||||||
|
const res = await req;
|
||||||
|
expect(res.data).toEqual(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const req = api.readMany("posts", {
|
||||||
|
select: ["title"],
|
||||||
|
limit: 100000,
|
||||||
|
offset: 0,
|
||||||
|
sort: "id"
|
||||||
|
});
|
||||||
|
expect(req.request.method).toBe("POST");
|
||||||
|
const res = await req;
|
||||||
|
expect(res.data).toEqual(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/// <reference types="@types/bun" />
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
|
||||||
|
import { MediaApi } from "../../src/media/api/MediaApi";
|
||||||
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
|
|
||||||
|
const mockedBackend = new Hono()
|
||||||
|
.basePath("/api/media")
|
||||||
|
.post("/upload/:name", async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
const body = await getFileFromContext(c);
|
||||||
|
return c.json({ name, is_file: isFile(body), size: body.size });
|
||||||
|
})
|
||||||
|
.get("/file/:name", async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
const file = Bun.file(`${assetsPath}/${name}`);
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
"Content-Length": file.size.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MediaApi", () => {
|
||||||
|
it("should give correct file upload url", () => {
|
||||||
|
const host = "http://localhost";
|
||||||
|
const basepath = "/api/media";
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({
|
||||||
|
host,
|
||||||
|
basepath
|
||||||
|
});
|
||||||
|
expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct upload headers", () => {
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({
|
||||||
|
token: "token"
|
||||||
|
});
|
||||||
|
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get file: native", async () => {
|
||||||
|
const name = "image.png";
|
||||||
|
const path = `${assetsTmpPath}/${name}`;
|
||||||
|
const res = await mockedBackend.request("/api/media/file/" + name);
|
||||||
|
await Bun.write(path, res);
|
||||||
|
|
||||||
|
const file = await Bun.file(path);
|
||||||
|
expect(file.size).toBeGreaterThan(0);
|
||||||
|
expect(file.type).toBe("image/png");
|
||||||
|
await file.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("download", async () => {
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
|
const name = "image.png";
|
||||||
|
const file = await api.download(name);
|
||||||
|
expect(isFile(file)).toBe(true);
|
||||||
|
expect(file.size).toBeGreaterThan(0);
|
||||||
|
expect(file.type).toBe("image/png");
|
||||||
|
expect(file.name).toContain(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getFile", async () => {
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
|
const name = "image.png";
|
||||||
|
const res = await api.getFile(name);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
// make sure it's a normal api request as usual
|
||||||
|
expect(res.res.ok).toBe(true);
|
||||||
|
expect(isReadableStream(res)).toBe(true);
|
||||||
|
expect(isReadableStream(res.body)).toBe(true);
|
||||||
|
expect(isReadableStream(res.res.body)).toBe(true);
|
||||||
|
|
||||||
|
const blob = await res.res.blob();
|
||||||
|
expect(isFile(blob)).toBe(true);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
expect(blob.type).toBe("image/png");
|
||||||
|
expect(blob.name).toContain(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getFileStream", async () => {
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
|
const name = "image.png";
|
||||||
|
const res = await api.getFileStream(name);
|
||||||
|
expect(isReadableStream(res)).toBe(true);
|
||||||
|
|
||||||
|
const blob = await new Response(res).blob();
|
||||||
|
expect(isFile(blob)).toBe(true);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
expect(blob.type).toBe("image/png");
|
||||||
|
expect(blob.name).toContain(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload file in various ways", async () => {
|
||||||
|
// @ts-ignore tests
|
||||||
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
const file = Bun.file(`${assetsPath}/image.png`);
|
||||||
|
|
||||||
|
async function matches(req: Promise<any>, filename: string) {
|
||||||
|
const res: any = await req;
|
||||||
|
expect(res.name).toBe(filename);
|
||||||
|
expect(res.is_file).toBe(true);
|
||||||
|
expect(res.size).toBe(file.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = "http://localhost/api/media/file/image.png";
|
||||||
|
|
||||||
|
// upload bun file
|
||||||
|
await matches(api.upload(file as any, { filename: "bunfile.png" }), "bunfile.png");
|
||||||
|
|
||||||
|
// upload via request
|
||||||
|
await matches(api.upload(new Request(url), { filename: "request.png" }), "request.png");
|
||||||
|
|
||||||
|
// upload via url
|
||||||
|
await matches(api.upload(url, { filename: "url.png" }), "url.png");
|
||||||
|
|
||||||
|
// upload via response
|
||||||
|
{
|
||||||
|
const response = await mockedBackend.request(url);
|
||||||
|
await matches(api.upload(response, { filename: "response.png" }), "response.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload via readable from bun
|
||||||
|
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
|
||||||
|
|
||||||
|
// upload via readable from response
|
||||||
|
{
|
||||||
|
const response = (await mockedBackend.request(url)) as Response;
|
||||||
|
await matches(
|
||||||
|
await api.upload(response.body!, { filename: "readable.png" }),
|
||||||
|
"readable.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { secureRandomString } from "../../src/core/utils";
|
||||||
|
import { ModuleApi } from "../../src/modules";
|
||||||
|
|
||||||
|
class Api extends ModuleApi {
|
||||||
|
_getUrl(path: string) {
|
||||||
|
return this.getUrl(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = "http://localhost";
|
||||||
|
|
||||||
|
describe("ModuleApi", () => {
|
||||||
|
it("resolves options correctly", () => {
|
||||||
|
const api = new Api({ host });
|
||||||
|
expect(api.options).toEqual({ host });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct url from path", () => {
|
||||||
|
const api = new Api({ host });
|
||||||
|
expect(api._getUrl("/test")).toEqual("http://localhost/test");
|
||||||
|
expect(api._getUrl("test")).toEqual("http://localhost/test");
|
||||||
|
expect(api._getUrl("test/")).toEqual("http://localhost/test");
|
||||||
|
expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches endpoint", async () => {
|
||||||
|
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||||
|
const api = new Api({ host }, app.request as typeof fetch);
|
||||||
|
|
||||||
|
const res = await api.get("/endpoint");
|
||||||
|
expect(res.res.ok).toEqual(true);
|
||||||
|
expect(res.res.status).toEqual(200);
|
||||||
|
expect(res.data).toEqual({ foo: "bar" });
|
||||||
|
expect(res.body).toEqual({ foo: "bar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has accessible request", async () => {
|
||||||
|
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||||
|
const api = new Api({ host }, app.request as typeof fetch);
|
||||||
|
|
||||||
|
const promise = api.get("/endpoint");
|
||||||
|
expect(promise.request).toBeDefined();
|
||||||
|
expect(promise.request.url).toEqual("http://localhost/endpoint");
|
||||||
|
|
||||||
|
expect((await promise).body).toEqual({ foo: "bar" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds token to headers when given in options", () => {
|
||||||
|
const token = secureRandomString(20);
|
||||||
|
const api = new Api({ host, token, token_transport: "header" });
|
||||||
|
|
||||||
|
expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets header to accept json", () => {
|
||||||
|
const api = new Api({ host });
|
||||||
|
expect(api.get("/").request.headers.get("Accept")).toEqual("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds additional headers from options", () => {
|
||||||
|
const headers = new Headers({
|
||||||
|
"X-Test": "123"
|
||||||
|
});
|
||||||
|
const api = new Api({ host, headers });
|
||||||
|
expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses basepath & removes trailing slash", () => {
|
||||||
|
const api = new Api({ host, basepath: "/api" });
|
||||||
|
expect(api.get("/").request.url).toEqual("http://localhost/api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses search params", () => {
|
||||||
|
const api = new Api({ host });
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
foo: "bar"
|
||||||
|
});
|
||||||
|
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves method shortcut fns correctly", () => {
|
||||||
|
const api = new Api({ host });
|
||||||
|
expect(api.get("/").request.method).toEqual("GET");
|
||||||
|
expect(api.post("/").request.method).toEqual("POST");
|
||||||
|
expect(api.put("/").request.method).toEqual("PUT");
|
||||||
|
expect(api.patch("/").request.method).toEqual("PATCH");
|
||||||
|
expect(api.delete("/").request.method).toEqual("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: test error response
|
||||||
|
// @todo: test method shortcut functions
|
||||||
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe, test } from "bun:test";
|
|
||||||
import { DataApi } from "../../src/modules/data/api/DataApi";
|
|
||||||
|
|
||||||
describe("Api", async () => {
|
|
||||||
test("...", async () => {
|
|
||||||
/*const dataApi = new DataApi({
|
|
||||||
host: "https://dev-config-soma.bknd.run"
|
|
||||||
});
|
|
||||||
|
|
||||||
const one = await dataApi.readOne("users", 1);
|
|
||||||
const many = await dataApi.readMany("users", { limit: 2 });
|
|
||||||
console.log("one", one);
|
|
||||||
console.log("many", many);*/
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createApp, registries } from "../../src";
|
||||||
|
import * as proto from "../../src/data/prototype";
|
||||||
|
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
||||||
|
|
||||||
|
describe("repros", async () => {
|
||||||
|
/**
|
||||||
|
* steps:
|
||||||
|
* 1. enable media
|
||||||
|
* 2. create 'test' entity
|
||||||
|
* 3. add media to 'test'
|
||||||
|
*
|
||||||
|
* There was an issue that AppData had old configs because of system entity "media"
|
||||||
|
*/
|
||||||
|
test("registers media entity correctly to relate to it", async () => {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
const app = createApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
{
|
||||||
|
// 1. enable media
|
||||||
|
const [, config] = await app.module.media.schema().patch("", {
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.enabled).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// 2. create 'test' entity
|
||||||
|
await app.module.data.schema().patch(
|
||||||
|
"entities.test",
|
||||||
|
proto
|
||||||
|
.entity("test", {
|
||||||
|
content: proto.text()
|
||||||
|
})
|
||||||
|
.toJSON()
|
||||||
|
);
|
||||||
|
expect(app.em.entities.map((e) => e.name)).toContain("test");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await app.module.data.schema().patch("entities.test.fields.files", {
|
||||||
|
type: "media",
|
||||||
|
config: {
|
||||||
|
required: false,
|
||||||
|
fillable: ["update"],
|
||||||
|
hidden: false,
|
||||||
|
mime_types: [],
|
||||||
|
virtual: true,
|
||||||
|
entity: "test"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
app.module.data.schema().patch("relations.000", {
|
||||||
|
type: "poly",
|
||||||
|
source: "test",
|
||||||
|
target: "media",
|
||||||
|
config: { mappedBy: "files" }
|
||||||
|
})
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.only("verify inversedBy", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
products: proto.entity("products", {
|
||||||
|
title: proto.text()
|
||||||
|
}),
|
||||||
|
product_likes: proto.entity("product_likes", {
|
||||||
|
created_at: proto.date()
|
||||||
|
}),
|
||||||
|
users: proto.entity("users", {})
|
||||||
|
},
|
||||||
|
(fns, schema) => {
|
||||||
|
fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" });
|
||||||
|
fns.relation(schema.product_likes).manyToOne(schema.users);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const app = createApp({ initialConfig: { data: schema.toJSON() } });
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||||
|
|
||||||
|
expect(info.fields).toEqual(["id", "title"]);
|
||||||
|
expect(info.relations.listable).toEqual([
|
||||||
|
{
|
||||||
|
entity: "product_likes",
|
||||||
|
ref: "likes"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,10 +16,8 @@ describe("authorize", () => {
|
|||||||
role: "admin"
|
role: "admin"
|
||||||
};
|
};
|
||||||
|
|
||||||
guard.setUserContext(user);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
expect(guard.granted("read")).toBe(true);
|
|
||||||
expect(guard.granted("write")).toBe(true);
|
|
||||||
|
|
||||||
expect(() => guard.granted("something")).toThrow();
|
expect(() => guard.granted("something")).toThrow();
|
||||||
});
|
});
|
||||||
@@ -46,10 +44,8 @@ describe("authorize", () => {
|
|||||||
role: "admin"
|
role: "admin"
|
||||||
};
|
};
|
||||||
|
|
||||||
guard.setUserContext(user);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
expect(guard.granted("read")).toBe(true);
|
|
||||||
expect(guard.granted("write")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard implicit allow", async () => {
|
test("guard implicit allow", async () => {
|
||||||
@@ -66,12 +62,12 @@ describe("authorize", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
guard.setUserContext({
|
const user = {
|
||||||
role: "admin"
|
role: "admin"
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard with guest role implicit allow", async () => {
|
test("guard with guest role implicit allow", async () => {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, expect, it, test } from "bun:test";
|
|
||||||
import { Endpoint } from "../../src/core";
|
|
||||||
import { mockFetch2, unmockFetch } from "./helper";
|
|
||||||
|
|
||||||
const testC: any = {
|
|
||||||
json: (res: any) => Response.json(res)
|
|
||||||
};
|
|
||||||
const testNext = async () => {};
|
|
||||||
|
|
||||||
describe("Endpoint", async () => {
|
|
||||||
it("behaves as expected", async () => {
|
|
||||||
const endpoint = new Endpoint("GET", "/test", async () => {
|
|
||||||
return { hello: "test" };
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(endpoint.method).toBe("GET");
|
|
||||||
expect(endpoint.path).toBe("/test");
|
|
||||||
|
|
||||||
const handler = endpoint.toHandler();
|
|
||||||
const response = await handler(testC, testNext);
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(await response.json()).toEqual({ hello: "test" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can be $request(ed)", async () => {
|
|
||||||
const obj = { hello: "test" };
|
|
||||||
const baseUrl = "https://local.com:123";
|
|
||||||
const endpoint = Endpoint.get("/test", async () => obj);
|
|
||||||
|
|
||||||
mockFetch2(async (input: RequestInfo, init: RequestInit) => {
|
|
||||||
expect(input).toBe(`${baseUrl}/test`);
|
|
||||||
return new Response(JSON.stringify(obj), { status: 200 });
|
|
||||||
});
|
|
||||||
const response = await endpoint.$request({}, baseUrl);
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
status: 200,
|
|
||||||
ok: true,
|
|
||||||
response: obj
|
|
||||||
});
|
|
||||||
unmockFetch();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves helper functions", async () => {
|
|
||||||
const params = ["/test", () => ({ hello: "test" })];
|
|
||||||
|
|
||||||
["get", "post", "patch", "put", "delete"].forEach((method) => {
|
|
||||||
const endpoint = Endpoint[method](...params);
|
|
||||||
expect(endpoint.method).toBe(method.toUpperCase());
|
|
||||||
expect(endpoint.path).toBe(params[0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||||
import { Event, EventManager, NoParamEvent } from "../../src/core/events";
|
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
class SpecialEvent extends Event<{ foo: string }> {
|
class SpecialEvent extends Event<{ foo: string }> {
|
||||||
static slug = "special-event";
|
static override slug = "special-event";
|
||||||
|
|
||||||
isBar() {
|
isBar() {
|
||||||
return this.params.foo === "bar";
|
return this.params.foo === "bar";
|
||||||
@@ -10,37 +14,139 @@ class SpecialEvent extends Event<{ foo: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class InformationalEvent extends NoParamEvent {
|
class InformationalEvent extends NoParamEvent {
|
||||||
static slug = "informational-event";
|
static override slug = "informational-event";
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReturnEvent extends Event<{ foo: string }, string> {
|
||||||
|
static override slug = "return-event";
|
||||||
|
|
||||||
|
override validate(value: string) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new InvalidEventReturn("string", typeof value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clone({
|
||||||
|
foo: [this.params.foo, value].join("-")
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("EventManager", async () => {
|
describe("EventManager", async () => {
|
||||||
test("test", async () => {
|
test("executes", async () => {
|
||||||
|
const call = mock(() => null);
|
||||||
|
const delayed = mock(() => null);
|
||||||
|
|
||||||
const emgr = new EventManager();
|
const emgr = new EventManager();
|
||||||
emgr.registerEvents([SpecialEvent, InformationalEvent]);
|
emgr.registerEvents([SpecialEvent, InformationalEvent]);
|
||||||
|
|
||||||
|
expect(emgr.eventExists("special-event")).toBe(true);
|
||||||
|
expect(emgr.eventExists("informational-event")).toBe(true);
|
||||||
|
expect(emgr.eventExists("unknown-event")).toBe(false);
|
||||||
|
|
||||||
emgr.onEvent(
|
emgr.onEvent(
|
||||||
SpecialEvent,
|
SpecialEvent,
|
||||||
async (event, name) => {
|
async (event, name) => {
|
||||||
console.log("Event: ", name, event.params.foo, event.isBar());
|
expect(name).toBe("special-event");
|
||||||
console.log("wait...");
|
expect(event.isBar()).toBe(true);
|
||||||
|
call();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
console.log("done waiting");
|
delayed();
|
||||||
},
|
},
|
||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// don't allow unknown
|
||||||
|
expect(() => emgr.on("unknown", () => void 0)).toThrow();
|
||||||
|
|
||||||
emgr.onEvent(InformationalEvent, async (event, name) => {
|
emgr.onEvent(InformationalEvent, async (event, name) => {
|
||||||
console.log("Event: ", name, event.params);
|
call();
|
||||||
|
expect(name).toBe("informational-event");
|
||||||
});
|
});
|
||||||
|
|
||||||
await emgr.emit(new SpecialEvent({ foo: "bar" }));
|
await emgr.emit(new SpecialEvent({ foo: "bar" }));
|
||||||
console.log("done");
|
await emgr.emit(new InformationalEvent());
|
||||||
|
|
||||||
// expect construct signatures to not cause ts errors
|
// expect construct signatures to not cause ts errors
|
||||||
new SpecialEvent({ foo: "bar" });
|
new SpecialEvent({ foo: "bar" });
|
||||||
new InformationalEvent();
|
new InformationalEvent();
|
||||||
|
|
||||||
expect(true).toBe(true);
|
expect(call).toHaveBeenCalledTimes(2);
|
||||||
|
expect(delayed).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom async executor", async () => {
|
||||||
|
const call = mock(() => null);
|
||||||
|
const asyncExecutor = (p: Promise<any>[]) => {
|
||||||
|
call();
|
||||||
|
return Promise.all(p);
|
||||||
|
};
|
||||||
|
const emgr = new EventManager(
|
||||||
|
{ InformationalEvent },
|
||||||
|
{
|
||||||
|
asyncExecutor
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
emgr.onEvent(InformationalEvent, async () => {});
|
||||||
|
await emgr.emit(new InformationalEvent());
|
||||||
|
expect(call).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("piping", async () => {
|
||||||
|
const onInvalidReturn = mock(() => null);
|
||||||
|
const asyncEventCallback = mock(() => null);
|
||||||
|
const emgr = new EventManager(
|
||||||
|
{ ReturnEvent, InformationalEvent },
|
||||||
|
{
|
||||||
|
onInvalidReturn
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error InformationalEvent has no return value
|
||||||
|
emgr.onEvent(InformationalEvent, async () => {
|
||||||
|
asyncEventCallback();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
emgr.onEvent(ReturnEvent, async () => "1", "sync");
|
||||||
|
emgr.onEvent(ReturnEvent, async () => "0", "sync");
|
||||||
|
|
||||||
|
// @ts-expect-error must be string
|
||||||
|
emgr.onEvent(ReturnEvent, async () => 0, "sync");
|
||||||
|
|
||||||
|
// return is not required
|
||||||
|
emgr.onEvent(ReturnEvent, async () => {}, "sync");
|
||||||
|
|
||||||
|
// was "async", will not return
|
||||||
|
const e1 = await emgr.emit(new InformationalEvent());
|
||||||
|
expect(e1.returned).toBe(false);
|
||||||
|
|
||||||
|
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
|
||||||
|
expect(e2.returned).toBe(true);
|
||||||
|
expect(e2.params.foo).toBe("bar-1-0");
|
||||||
|
expect(onInvalidReturn).toHaveBeenCalled();
|
||||||
|
expect(asyncEventCallback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once", async () => {
|
||||||
|
const call = mock(() => null);
|
||||||
|
const emgr = new EventManager({ InformationalEvent });
|
||||||
|
|
||||||
|
emgr.onEvent(
|
||||||
|
InformationalEvent,
|
||||||
|
async (event, slug) => {
|
||||||
|
expect(event).toBeInstanceOf(InformationalEvent);
|
||||||
|
expect(slug).toBe("informational-event");
|
||||||
|
call();
|
||||||
|
},
|
||||||
|
{ mode: "sync", once: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(emgr.getListeners().length).toBe(1);
|
||||||
|
await emgr.emit(new InformationalEvent());
|
||||||
|
expect(emgr.getListeners().length).toBe(0);
|
||||||
|
await emgr.emit(new InformationalEvent());
|
||||||
|
expect(emgr.getListeners().length).toBe(0);
|
||||||
|
expect(call).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { TObject, TString } from "@sinclair/typebox";
|
import type { TObject, TString } from "@sinclair/typebox";
|
||||||
import { Registry } from "../../src/core/registry/Registry";
|
import { Registry } from "../../src/core/registry/Registry";
|
||||||
import { type TSchema, Type } from "../../src/core/utils";
|
import { type TSchema, Type } from "../../src/core/utils";
|
||||||
@@ -11,6 +11,9 @@ class What {
|
|||||||
method() {
|
method() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
getType() {
|
||||||
|
return Type.Object({ type: Type.String() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
class What2 extends What {}
|
class What2 extends What {}
|
||||||
class NotAllowed {}
|
class NotAllowed {}
|
||||||
@@ -32,25 +35,53 @@ describe("Registry", () => {
|
|||||||
} satisfies Record<string, Test1>);
|
} satisfies Record<string, Test1>);
|
||||||
|
|
||||||
const item = registry.get("first");
|
const item = registry.get("first");
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item?.cls).toBe(What);
|
||||||
|
|
||||||
|
const second = Type.Object({ type: Type.String(), what: Type.String() });
|
||||||
registry.add("second", {
|
registry.add("second", {
|
||||||
cls: What2,
|
cls: What2,
|
||||||
schema: Type.Object({ type: Type.String(), what: Type.String() }),
|
schema: second,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
expect(registry.get("second").schema).toEqual(second);
|
||||||
|
|
||||||
|
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
|
||||||
registry.add("third", {
|
registry.add("third", {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
cls: NotAllowed,
|
cls: NotAllowed,
|
||||||
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
|
schema: third,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
expect(registry.get("third").schema).toEqual(third);
|
||||||
|
|
||||||
|
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
|
||||||
registry.add("fourth", {
|
registry.add("fourth", {
|
||||||
cls: What,
|
cls: What,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
|
schema: fourth,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
expect(registry.get("fourth").schema).toEqual(fourth);
|
||||||
|
|
||||||
console.log("list", registry.all());
|
expect(Object.keys(registry.all()).length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses registration fn", async () => {
|
||||||
|
const registry = new Registry<Test1>((a: ClassRef<What>) => {
|
||||||
|
return {
|
||||||
|
cls: a,
|
||||||
|
schema: a.prototype.getType(),
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.register("what2", What2);
|
||||||
|
expect(registry.get("what2")).toBeDefined();
|
||||||
|
expect(registry.get("what2").cls).toBe(What2);
|
||||||
|
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { checksum, hash } from "../../src/core/utils";
|
import { checksum, hash } from "../../src/core/utils";
|
||||||
|
|
||||||
describe("crypto", async () => {
|
describe("crypto", async () => {
|
||||||
test("sha256", async () => {
|
test("sha256", async () => {
|
||||||
console.log(await hash.sha256("test"));
|
expect(await hash.sha256("test")).toBe(
|
||||||
|
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
test("sha1", async () => {
|
test("sha1", async () => {
|
||||||
console.log(await hash.sha1("test"));
|
expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
|
||||||
});
|
});
|
||||||
test("checksum", async () => {
|
test("checksum", async () => {
|
||||||
console.log(checksum("hello world"));
|
expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import { describe, expect, it, test } from "bun:test";
|
||||||
|
import { apply, diff, revert } from "../../../src/core/object/diff";
|
||||||
|
|
||||||
|
describe("diff", () => {
|
||||||
|
it("should detect added properties", () => {
|
||||||
|
const oldObj = { a: 1 };
|
||||||
|
const newObj = { a: 1, b: 2 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["b"],
|
||||||
|
o: undefined,
|
||||||
|
n: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect removed properties", () => {
|
||||||
|
const oldObj = { a: 1, b: 2 };
|
||||||
|
const newObj = { a: 1 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "r",
|
||||||
|
p: ["b"],
|
||||||
|
o: 2,
|
||||||
|
n: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect edited properties", () => {
|
||||||
|
const oldObj = { a: 1 };
|
||||||
|
const newObj = { a: 2 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a"],
|
||||||
|
o: 1,
|
||||||
|
n: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect changes in nested objects", () => {
|
||||||
|
const oldObj = { a: { b: 1 } };
|
||||||
|
const newObj = { a: { b: 2 } };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a", "b"],
|
||||||
|
o: 1,
|
||||||
|
n: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect changes in arrays", () => {
|
||||||
|
const oldObj = { a: [1, 2, 3] };
|
||||||
|
const newObj = { a: [1, 4, 3, 5] };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a", 1],
|
||||||
|
o: 2,
|
||||||
|
n: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a", 3],
|
||||||
|
o: undefined,
|
||||||
|
n: 5
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle adding elements to an empty array", () => {
|
||||||
|
const oldObj = { a: [] };
|
||||||
|
const newObj = { a: [1, 2, 3] };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a", 0],
|
||||||
|
o: undefined,
|
||||||
|
n: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a", 1],
|
||||||
|
o: undefined,
|
||||||
|
n: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a", 2],
|
||||||
|
o: undefined,
|
||||||
|
n: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle removing elements from an array", () => {
|
||||||
|
const oldObj = { a: [1, 2, 3] };
|
||||||
|
const newObj = { a: [1, 3] };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a", 1],
|
||||||
|
o: 2,
|
||||||
|
n: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "r",
|
||||||
|
p: ["a", 2],
|
||||||
|
o: 3,
|
||||||
|
n: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex nested changes", () => {
|
||||||
|
const oldObj = {
|
||||||
|
a: {
|
||||||
|
b: [1, 2, { c: 3 }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const newObj = {
|
||||||
|
a: {
|
||||||
|
b: [1, 2, { c: 4 }, 5]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a", "b", 2, "c"],
|
||||||
|
o: 3,
|
||||||
|
n: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a", "b", 3],
|
||||||
|
o: undefined,
|
||||||
|
n: 5
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined and null values", () => {
|
||||||
|
const oldObj = { a: undefined, b: null };
|
||||||
|
const newObj = { a: null, b: undefined };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a"],
|
||||||
|
o: undefined,
|
||||||
|
n: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["b"],
|
||||||
|
o: null,
|
||||||
|
n: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle type changes", () => {
|
||||||
|
const oldObj = { a: 1 };
|
||||||
|
const newObj = { a: "1" };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a"],
|
||||||
|
o: 1,
|
||||||
|
n: "1"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle properties added and removed simultaneously", () => {
|
||||||
|
const oldObj = { a: 1, b: 2 };
|
||||||
|
const newObj = { a: 1, c: 3 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "r",
|
||||||
|
p: ["b"],
|
||||||
|
o: 2,
|
||||||
|
n: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["c"],
|
||||||
|
o: undefined,
|
||||||
|
n: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle arrays replaced with objects", () => {
|
||||||
|
const oldObj = { a: [1, 2, 3] };
|
||||||
|
const newObj = { a: { b: 4 } };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a"],
|
||||||
|
o: [1, 2, 3],
|
||||||
|
n: { b: 4 }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle objects replaced with primitives", () => {
|
||||||
|
const oldObj = { a: { b: 1 } };
|
||||||
|
const newObj = { a: 2 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "e",
|
||||||
|
p: ["a"],
|
||||||
|
o: { b: 1 },
|
||||||
|
n: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle root object changes", () => {
|
||||||
|
const oldObj = { a: 1 };
|
||||||
|
const newObj = { b: 2 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "r",
|
||||||
|
p: ["a"],
|
||||||
|
o: 1,
|
||||||
|
n: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["b"],
|
||||||
|
o: undefined,
|
||||||
|
n: 2
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle identical objects", () => {
|
||||||
|
const oldObj = { a: 1, b: { c: 2 } };
|
||||||
|
const newObj = { a: 1, b: { c: 2 } };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty objects", () => {
|
||||||
|
const oldObj = {};
|
||||||
|
const newObj = {};
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle changes from empty object to non-empty object", () => {
|
||||||
|
const oldObj = {};
|
||||||
|
const newObj = { a: 1 };
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "a",
|
||||||
|
p: ["a"],
|
||||||
|
o: undefined,
|
||||||
|
n: 1
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle changes from non-empty object to empty object", () => {
|
||||||
|
const oldObj = { a: 1 };
|
||||||
|
const newObj = {};
|
||||||
|
|
||||||
|
const diffs = diff(oldObj, newObj);
|
||||||
|
|
||||||
|
expect(diffs).toEqual([
|
||||||
|
{
|
||||||
|
t: "r",
|
||||||
|
p: ["a"],
|
||||||
|
o: 1,
|
||||||
|
n: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appliedObj = apply(oldObj, diffs);
|
||||||
|
expect(appliedObj).toEqual(newObj);
|
||||||
|
|
||||||
|
const revertedObj = revert(newObj, diffs);
|
||||||
|
expect(revertedObj).toEqual(oldObj);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
||||||
import { deprecated__whereRepoSchema } from "../../../src/data";
|
|
||||||
|
|
||||||
describe("object-query", () => {
|
describe("object-query", () => {
|
||||||
const q: ObjectQuery = { name: "Michael" };
|
const q: ObjectQuery = { name: "Michael" };
|
||||||
@@ -8,19 +7,6 @@ describe("object-query", () => {
|
|||||||
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
|
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
|
||||||
const bag = { q, q2, q3 };
|
const bag = { q, q2, q3 };
|
||||||
|
|
||||||
test("translates into legacy", async () => {
|
|
||||||
for (const [key, value] of Object.entries(bag)) {
|
|
||||||
const obj = convert(value);
|
|
||||||
try {
|
|
||||||
const parsed = deprecated__whereRepoSchema.parse(obj);
|
|
||||||
expect(parsed).toBeDefined();
|
|
||||||
} catch (e) {
|
|
||||||
console.log("errored", { obj, value });
|
|
||||||
console.error(key, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validates", async () => {
|
test("validates", async () => {
|
||||||
const converted = convert({
|
const converted = convert({
|
||||||
name: { $eq: "ch" }
|
name: { $eq: "ch" }
|
||||||
|
|||||||
+150
-13
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Perf } from "../../src/core/utils";
|
import { Perf, isBlob, ucFirst } from "../../src/core/utils";
|
||||||
import * as reqres from "../../src/core/utils/reqres";
|
import * as utils from "../../src/core/utils";
|
||||||
import * as strings from "../../src/core/utils/strings";
|
|
||||||
|
|
||||||
async function wait(ms: number) {
|
async function wait(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
|
|||||||
describe("[core] strings", async () => {
|
describe("[core] strings", async () => {
|
||||||
test("objectToKeyValueArray", async () => {
|
test("objectToKeyValueArray", async () => {
|
||||||
const obj = { a: 1, b: 2, c: 3 };
|
const obj = { a: 1, b: 2, c: 3 };
|
||||||
const result = strings.objectToKeyValueArray(obj);
|
const result = utils.objectToKeyValueArray(obj);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ key: "a", value: 1 },
|
{ key: "a", value: 1 },
|
||||||
{ key: "b", value: 2 },
|
{ key: "b", value: 2 },
|
||||||
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("snakeToPascalWithSpaces", async () => {
|
test("snakeToPascalWithSpaces", async () => {
|
||||||
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
|
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
|
||||||
expect(result).toBe("Snake To Pascal");
|
expect(result).toBe("Snake To Pascal");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("randomString", async () => {
|
test("randomString", async () => {
|
||||||
const result = strings.randomString(10);
|
const result = utils.randomString(10);
|
||||||
expect(result).toHaveLength(10);
|
expect(result).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pascalToKebab", async () => {
|
test("pascalToKebab", async () => {
|
||||||
const result = strings.pascalToKebab("PascalCase");
|
const result = utils.pascalToKebab("PascalCase");
|
||||||
expect(result).toBe("pascal-case");
|
expect(result).toBe("pascal-case");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaceSimplePlaceholders", async () => {
|
test("replaceSimplePlaceholders", async () => {
|
||||||
const str = "Hello, {$name}!";
|
const str = "Hello, {$name}!";
|
||||||
const vars = { name: "John" };
|
const vars = { name: "John" };
|
||||||
const result = strings.replaceSimplePlaceholders(str, vars);
|
const result = utils.replaceSimplePlaceholders(str, vars);
|
||||||
expect(result).toBe("Hello, John!");
|
expect(result).toBe("Hello, John!");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
|
|||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.append("Content-Type", "application/json");
|
headers.append("Content-Type", "application/json");
|
||||||
headers.append("Authorization", "Bearer 123");
|
headers.append("Authorization", "Bearer 123");
|
||||||
const obj = reqres.headersToObject(headers);
|
const obj = utils.headersToObject(headers);
|
||||||
expect(obj).toEqual({
|
expect(obj).toEqual({
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: "Bearer 123"
|
authorization: "Bearer 123"
|
||||||
@@ -59,23 +58,74 @@ describe("Core Utils", async () => {
|
|||||||
test("replaceUrlParam", () => {
|
test("replaceUrlParam", () => {
|
||||||
const url = "/api/:id/:name";
|
const url = "/api/:id/:name";
|
||||||
const params = { id: "123", name: "test" };
|
const params = { id: "123", name: "test" };
|
||||||
const result = reqres.replaceUrlParam(url, params);
|
const result = utils.replaceUrlParam(url, params);
|
||||||
expect(result).toBe("/api/123/test");
|
expect(result).toBe("/api/123/test");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("encode", () => {
|
test("encode", () => {
|
||||||
const obj = { id: "123", name: "test" };
|
const obj = { id: "123", name: "test" };
|
||||||
const result = reqres.encodeSearch(obj);
|
const result = utils.encodeSearch(obj);
|
||||||
expect(result).toBe("id=123&name=test");
|
expect(result).toBe("id=123&name=test");
|
||||||
|
|
||||||
const obj2 = { id: "123", name: ["test1", "test2"] };
|
const obj2 = { id: "123", name: ["test1", "test2"] };
|
||||||
const result2 = reqres.encodeSearch(obj2);
|
const result2 = utils.encodeSearch(obj2);
|
||||||
expect(result2).toBe("id=123&name=test1&name=test2");
|
expect(result2).toBe("id=123&name=test1&name=test2");
|
||||||
|
|
||||||
const obj3 = { id: "123", name: { test: "test" } };
|
const obj3 = { id: "123", name: { test: "test" } };
|
||||||
const result3 = reqres.encodeSearch(obj3, { encode: true });
|
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("guards", () => {
|
||||||
|
const types = {
|
||||||
|
blob: new Blob(),
|
||||||
|
file: new File([""], "file.txt"),
|
||||||
|
stream: new ReadableStream(),
|
||||||
|
arrayBuffer: new ArrayBuffer(10),
|
||||||
|
arrayBufferView: new Uint8Array(new ArrayBuffer(10))
|
||||||
|
};
|
||||||
|
|
||||||
|
const fns = [
|
||||||
|
[utils.isReadableStream, "stream"],
|
||||||
|
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isArrayBuffer, "arrayBuffer"],
|
||||||
|
[utils.isArrayBufferView, "arrayBufferView"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const additional = [0, 0.0, "", null, undefined, {}, []];
|
||||||
|
|
||||||
|
for (const [fn, type, _to_test] of fns) {
|
||||||
|
test(`is${ucFirst(type)}`, () => {
|
||||||
|
const to_test = _to_test ?? (Object.keys(types) as string[]);
|
||||||
|
for (const key of to_test) {
|
||||||
|
const value = types[key as keyof typeof types];
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(key === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of additional) {
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getContentName", () => {
|
||||||
|
const name = "test.json";
|
||||||
|
const text = "attachment; filename=" + name;
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Disposition": text
|
||||||
|
});
|
||||||
|
const request = new Request("http://example.com", {
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(utils.getContentName(text)).toBe(name);
|
||||||
|
expect(utils.getContentName(headers)).toBe(name);
|
||||||
|
expect(utils.getContentName(request)).toBe(name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("perf", async () => {
|
describe("perf", async () => {
|
||||||
@@ -108,4 +158,91 @@ describe("Core Utils", async () => {
|
|||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("objects", () => {
|
||||||
|
test("omitKeys", () => {
|
||||||
|
const objects = [
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
|
||||||
|
] as [object, string[], object][];
|
||||||
|
|
||||||
|
for (const [obj, keys, expected] of objects) {
|
||||||
|
const result = utils.omitKeys(obj, keys as any);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isEqual", () => {
|
||||||
|
const objects = [
|
||||||
|
[1, 1, true],
|
||||||
|
[1, "1", false],
|
||||||
|
[1, 2, false],
|
||||||
|
["1", "1", true],
|
||||||
|
["1", "2", false],
|
||||||
|
[true, true, true],
|
||||||
|
[true, false, false],
|
||||||
|
[false, false, true],
|
||||||
|
[1, Number.NaN, false],
|
||||||
|
[Number.NaN, Number.NaN, true],
|
||||||
|
[null, null, true],
|
||||||
|
[null, undefined, false],
|
||||||
|
[undefined, undefined, true],
|
||||||
|
[new Map([["a", 1]]), new Map([["a", 1]]), true],
|
||||||
|
[new Map([["a", 1]]), new Map([["a", 2]]), false],
|
||||||
|
[new Map([["a", 1]]), new Map([["b", 1]]), false],
|
||||||
|
[
|
||||||
|
new Map([["a", 1]]),
|
||||||
|
new Map([
|
||||||
|
["a", 1],
|
||||||
|
["b", 2]
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[{ a: 1 }, { a: 1 }, true],
|
||||||
|
[{ a: 1 }, { a: 2 }, false],
|
||||||
|
[{ a: 1 }, { b: 1 }, false],
|
||||||
|
[{ a: "1" }, { a: "1" }, true],
|
||||||
|
[{ a: "1" }, { a: "2" }, false],
|
||||||
|
[{ a: "1" }, { b: "1" }, false],
|
||||||
|
[{ a: 1 }, { a: 1, b: 2 }, false],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
|
||||||
|
[[1, 2, 3], [1, 2, 3], true],
|
||||||
|
[[1, 2, 3], [1, 2, 4], false],
|
||||||
|
[[1, 2, 3], [1, 2, 3, 4], false],
|
||||||
|
[[{ a: 1 }], [{ a: 1 }], true],
|
||||||
|
[[{ a: 1 }], [{ a: 2 }], false],
|
||||||
|
[[{ a: 1 }], [{ b: 1 }], false]
|
||||||
|
] as [any, any, boolean][];
|
||||||
|
|
||||||
|
for (const [a, b, expected] of objects) {
|
||||||
|
const result = utils.isEqual(a, b);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getPath", () => {
|
||||||
|
const tests = [
|
||||||
|
[{ a: 1, b: 2, c: 3 }, "a", 1],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, "b", 2],
|
||||||
|
[{ a: { b: 1 } }, "a.b", 1],
|
||||||
|
[{ a: { b: 1 } }, "a.b.c", null, null],
|
||||||
|
[{ a: { b: 1 } }, "a.b.c", 1, 1],
|
||||||
|
[[[1]], "0.0", 1]
|
||||||
|
] as [object, string, any, any][];
|
||||||
|
|
||||||
|
for (const [obj, path, expected, defaultValue] of tests) {
|
||||||
|
const result = utils.getPath(obj, path, defaultValue);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
|
|||||||
//console.log("app.routes", app.routes);
|
//console.log("app.routes", app.routes);
|
||||||
// create users
|
// create users
|
||||||
for await (const _user of fixtures.users) {
|
for await (const _user of fixtures.users) {
|
||||||
const res = await app.request("/users", {
|
const res = await app.request("/entity/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_user)
|
body: JSON.stringify(_user)
|
||||||
});
|
});
|
||||||
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
|
|||||||
|
|
||||||
// create posts
|
// create posts
|
||||||
for await (const _post of fixtures.posts) {
|
for await (const _post of fixtures.posts) {
|
||||||
const res = await app.request("/posts", {
|
const res = await app.request("/entity/posts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_post)
|
body: JSON.stringify(_post)
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (read many)", async () => {
|
test("/:entity (read many)", async () => {
|
||||||
const res = await app.request("/users");
|
const res = await app.request("/entity/users");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
expect(data.meta.total).toBe(3);
|
||||||
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/query (func query)", async () => {
|
test("/:entity/query (func query)", async () => {
|
||||||
const res = await app.request("/users/query", {
|
const res = await app.request("/entity/users/query", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (read many, paginated)", async () => {
|
test("/:entity (read many, paginated)", async () => {
|
||||||
const res = await app.request("/users?limit=1&offset=2");
|
const res = await app.request("/entity/users?limit=1&offset=2");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
expect(data.meta.total).toBe(3);
|
||||||
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (read one)", async () => {
|
test("/:entity/:id (read one)", async () => {
|
||||||
const res = await app.request("/users/3");
|
const res = await app.request("/entity/users/3");
|
||||||
const data = (await res.json()) as RepositoryResponse<EntityData>;
|
const data = (await res.json()) as RepositoryResponse<EntityData>;
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (update one)", async () => {
|
test("/:entity (update one)", async () => {
|
||||||
const res = await app.request("/users/3", {
|
const res = await app.request("/entity/users/3", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name: "new name" })
|
body: JSON.stringify({ name: "new name" })
|
||||||
});
|
});
|
||||||
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id/:reference (read references)", async () => {
|
test("/:entity/:id/:reference (read references)", async () => {
|
||||||
const res = await app.request("/users/1/posts");
|
const res = await app.request("/entity/users/1/posts");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|
||||||
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (delete one)", async () => {
|
test("/:entity/:id (delete one)", async () => {
|
||||||
const res = await app.request("/posts/2", {
|
const res = await app.request("/entity/posts/2", {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
|
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
|
||||||
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
||||||
|
|
||||||
// verify
|
// verify
|
||||||
const res2 = await app.request("/posts");
|
const res2 = await app.request("/entity/posts");
|
||||||
const data2 = (await res2.json()) as RepositoryResponse;
|
const data2 = (await res2.json()) as RepositoryResponse;
|
||||||
expect(data2.meta.total).toBe(1);
|
expect(data2.meta.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { QueryObject } from "ufo";
|
import { Value, _jsonp } from "../../src/core/utils";
|
||||||
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
|
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||||
|
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
||||||
import { getDummyConnection } from "./helper";
|
import { getDummyConnection } from "./helper";
|
||||||
|
|
||||||
const t = "t";
|
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
||||||
|
const result = Value.Decode(querySchema, input);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
};
|
||||||
|
|
||||||
describe("data-query-impl", () => {
|
describe("data-query-impl", () => {
|
||||||
function qb() {
|
function qb() {
|
||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const kysely = c.dummyConnection.kysely;
|
const kysely = c.dummyConnection.kysely;
|
||||||
return kysely.selectFrom(t).selectAll();
|
return kysely.selectFrom("t").selectAll();
|
||||||
}
|
}
|
||||||
function compile(q: QueryObject) {
|
function compile(q: WhereQuery) {
|
||||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||||
return { sql, parameters };
|
return { sql, parameters };
|
||||||
}
|
}
|
||||||
@@ -89,4 +94,64 @@ describe("data-query-impl", () => {
|
|||||||
expect(keys).toEqual(expectedKeys);
|
expect(keys).toEqual(expectedKeys);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("with", () => {
|
||||||
|
decode({ with: ["posts"] }, { with: { posts: {} } });
|
||||||
|
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
||||||
|
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
||||||
|
decode(
|
||||||
|
{
|
||||||
|
with: {
|
||||||
|
posts: {
|
||||||
|
with: {
|
||||||
|
images: {
|
||||||
|
select: ["id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
with: {
|
||||||
|
posts: {
|
||||||
|
with: {
|
||||||
|
images: {
|
||||||
|
select: ["id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// over http
|
||||||
|
{
|
||||||
|
const output = { with: { images: {} } };
|
||||||
|
decode({ with: "images" }, output);
|
||||||
|
decode({ with: '["images"]' }, output);
|
||||||
|
decode({ with: ["images"] }, output);
|
||||||
|
decode({ with: { images: {} } }, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const output = { with: { images: {}, comments: {} } };
|
||||||
|
decode({ with: "images,comments" }, output);
|
||||||
|
decode({ with: ["images", "comments"] }, output);
|
||||||
|
decode({ with: '["images", "comments"]' }, output);
|
||||||
|
decode({ with: { images: {}, comments: {} } }, output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data-query-impl: Typebox", () => {
|
||||||
|
test("sort", async () => {
|
||||||
|
const _dflt = { sort: { by: "id", dir: "asc" } };
|
||||||
|
|
||||||
|
decode({ sort: "" }, _dflt);
|
||||||
|
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
||||||
|
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
||||||
|
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
||||||
|
decode({ sort: "-1name" }, _dflt);
|
||||||
|
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("some tests", async () => {
|
|||||||
|
|
||||||
const users = new Entity("users", [
|
const users = new Entity("users", [
|
||||||
new TextField("username", { required: true, default_value: "nobody" }),
|
new TextField("username", { required: true, default_value: "nobody" }),
|
||||||
new TextField("email", { max_length: 3 })
|
new TextField("email", { maxLength: 3 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const posts = new Entity("posts", [
|
const posts = new Entity("posts", [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
|
|||||||
new TextField("label", { required: true, minLength: 1 }),
|
new TextField("label", { required: true, minLength: 1 }),
|
||||||
new NumberField("count", { default_value: 0 })
|
new NumberField("count", { default_value: 0 })
|
||||||
]);
|
]);
|
||||||
const em = new EntityManager([items], connection);
|
const em = new EntityManager<any>([items], connection);
|
||||||
|
|
||||||
await em.connection.kysely.schema
|
await em.connection.kysely.schema
|
||||||
.createTable("items")
|
.createTable("items")
|
||||||
@@ -132,14 +132,61 @@ describe("Mutator simple", async () => {
|
|||||||
const data = (await em.repository(items).findMany()).data;
|
const data = (await em.repository(items).findMany()).data;
|
||||||
//console.log(data);
|
//console.log(data);
|
||||||
|
|
||||||
await em.mutator(items).deleteMany({ label: "delete" });
|
await em.mutator(items).deleteWhere({ label: "delete" });
|
||||||
|
|
||||||
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
|
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
|
||||||
//console.log((await em.repository(items).findMany()).data);
|
//console.log((await em.repository(items).findMany()).data);
|
||||||
|
|
||||||
await em.mutator(items).deleteMany();
|
await em.mutator(items).deleteWhere();
|
||||||
expect((await em.repository(items).findMany()).data.length).toBe(0);
|
expect((await em.repository(items).findMany()).data.length).toBe(0);
|
||||||
|
|
||||||
//expect(res.data.count).toBe(0);
|
//expect(res.data.count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updateMany", async () => {
|
||||||
|
await em.mutator(items).insertOne({ label: "update", count: 1 });
|
||||||
|
await em.mutator(items).insertOne({ label: "update too", count: 1 });
|
||||||
|
await em.mutator(items).insertOne({ label: "keep" });
|
||||||
|
|
||||||
|
// expect no update
|
||||||
|
await em.mutator(items).updateWhere(
|
||||||
|
{ count: 2 },
|
||||||
|
{
|
||||||
|
count: 10
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect((await em.repository(items).findMany()).data).toEqual([
|
||||||
|
{ id: 6, label: "update", count: 1 },
|
||||||
|
{ id: 7, label: "update too", count: 1 },
|
||||||
|
{ id: 8, label: "keep", count: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// expect 2 to be updated
|
||||||
|
await em.mutator(items).updateWhere(
|
||||||
|
{ count: 2 },
|
||||||
|
{
|
||||||
|
count: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await em.repository(items).findMany()).data).toEqual([
|
||||||
|
{ id: 6, label: "update", count: 2 },
|
||||||
|
{ id: 7, label: "update too", count: 2 },
|
||||||
|
{ id: 8, label: "keep", count: 0 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertMany", async () => {
|
||||||
|
const oldCount = (await em.repo(items).count()).count;
|
||||||
|
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
|
||||||
|
const { data } = await em.mutator(items).insertMany(inserts);
|
||||||
|
|
||||||
|
expect(data.length).toBe(2);
|
||||||
|
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
|
||||||
|
const newCount = (await em.repo(items).count()).count;
|
||||||
|
expect(newCount).toBe(oldCount + inserts.length);
|
||||||
|
|
||||||
|
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
|
||||||
|
expect(data2).toEqual(data);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { MediaField } from "../../src";
|
|
||||||
import {
|
import {
|
||||||
BooleanField,
|
BooleanField,
|
||||||
DateField,
|
DateField,
|
||||||
Entity,
|
Entity,
|
||||||
|
EntityIndex,
|
||||||
|
EntityManager,
|
||||||
EnumField,
|
EnumField,
|
||||||
JsonField,
|
JsonField,
|
||||||
ManyToManyRelation,
|
ManyToManyRelation,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
PolymorphicRelation,
|
PolymorphicRelation,
|
||||||
TextField
|
TextField
|
||||||
} from "../../src/data";
|
} from "../../src/data";
|
||||||
|
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||||
import {
|
import {
|
||||||
FieldPrototype,
|
FieldPrototype,
|
||||||
type FieldSchema,
|
type FieldSchema,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
date,
|
date,
|
||||||
datetime,
|
datetime,
|
||||||
|
em,
|
||||||
entity,
|
entity,
|
||||||
enumm,
|
enumm,
|
||||||
json,
|
json,
|
||||||
@@ -30,6 +33,7 @@ import {
|
|||||||
relation,
|
relation,
|
||||||
text
|
text
|
||||||
} from "../../src/data/prototype";
|
} from "../../src/data/prototype";
|
||||||
|
import { MediaField } from "../../src/media/MediaField";
|
||||||
|
|
||||||
describe("prototype", () => {
|
describe("prototype", () => {
|
||||||
test("...", () => {
|
test("...", () => {
|
||||||
@@ -46,12 +50,17 @@ describe("prototype", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("...2", async () => {
|
test("...2", async () => {
|
||||||
const user = entity("users", {
|
const users = entity("users", {
|
||||||
name: text().required(),
|
name: text(),
|
||||||
bio: text(),
|
bio: text(),
|
||||||
age: number(),
|
age: number(),
|
||||||
some: number().required()
|
some: number()
|
||||||
});
|
});
|
||||||
|
type db = {
|
||||||
|
users: Schema<typeof users>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const obj: Schema<typeof users> = {} as any;
|
||||||
|
|
||||||
//console.log("user", user.toJSON());
|
//console.log("user", user.toJSON());
|
||||||
});
|
});
|
||||||
@@ -76,7 +85,9 @@ describe("prototype", () => {
|
|||||||
new DateField("created_at", {
|
new DateField("created_at", {
|
||||||
type: "datetime"
|
type: "datetime"
|
||||||
}),
|
}),
|
||||||
|
// @ts-ignore
|
||||||
new MediaField("images", { entity: "posts" }),
|
new MediaField("images", { entity: "posts" }),
|
||||||
|
// @ts-ignore
|
||||||
new MediaField("cover", { entity: "posts", max_items: 1 })
|
new MediaField("cover", { entity: "posts", max_items: 1 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -264,4 +275,38 @@ describe("prototype", () => {
|
|||||||
|
|
||||||
const obj: Schema<typeof test> = {} as any;
|
const obj: Schema<typeof test> = {} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("schema", async () => {
|
||||||
|
const _em = em(
|
||||||
|
{
|
||||||
|
posts: entity("posts", { name: text(), slug: text().required() }),
|
||||||
|
comments: entity("comments", { some: text() }),
|
||||||
|
users: entity("users", { email: text() })
|
||||||
|
},
|
||||||
|
({ relation, index }, { posts, comments, users }) => {
|
||||||
|
relation(posts).manyToOne(comments).manyToOne(users);
|
||||||
|
index(posts).on(["name"]).on(["slug"], true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type LocalDb = (typeof _em)["DB"];
|
||||||
|
|
||||||
|
const es = [
|
||||||
|
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
|
||||||
|
new Entity("comments", [new TextField("some")]),
|
||||||
|
new Entity("users", [new TextField("email")])
|
||||||
|
];
|
||||||
|
const _em2 = new EntityManager(
|
||||||
|
es,
|
||||||
|
new DummyConnection(),
|
||||||
|
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
|
||||||
|
[
|
||||||
|
new EntityIndex(es[0], [es[0].field("name")!]),
|
||||||
|
new EntityIndex(es[0], [es[0].field("slug")!], true)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
expect(_em2.toJSON()).toEqual(_em.toJSON());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ describe("Relations", async () => {
|
|||||||
expect(postAuthorRel?.other(posts).entity).toBe(users);
|
expect(postAuthorRel?.other(posts).entity).toBe(users);
|
||||||
|
|
||||||
const kysely = em.connection.kysely;
|
const kysely = em.connection.kysely;
|
||||||
const jsonFrom = (e) => e;
|
|
||||||
/**
|
/**
|
||||||
* Relation Helper
|
* Relation Helper
|
||||||
*/
|
*/
|
||||||
@@ -119,14 +118,11 @@ describe("Relations", async () => {
|
|||||||
- select: users.*
|
- select: users.*
|
||||||
- cardinality: 1
|
- cardinality: 1
|
||||||
*/
|
*/
|
||||||
const selectPostsFromUsers = postAuthorRel.buildWith(
|
const selectPostsFromUsers = kysely
|
||||||
users,
|
.selectFrom(users.name)
|
||||||
kysely.selectFrom(users.name),
|
.select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts"));
|
||||||
jsonFrom,
|
|
||||||
"posts"
|
|
||||||
);
|
|
||||||
expect(selectPostsFromUsers.compile().sql).toBe(
|
expect(selectPostsFromUsers.compile().sql).toBe(
|
||||||
'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"'
|
'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"'
|
||||||
);
|
);
|
||||||
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
|
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
|
||||||
const userObj = { id: 1, username: "test" };
|
const userObj = { id: 1, username: "test" };
|
||||||
@@ -141,15 +137,12 @@ describe("Relations", async () => {
|
|||||||
- select: posts.*
|
- select: posts.*
|
||||||
- cardinality:
|
- cardinality:
|
||||||
*/
|
*/
|
||||||
const selectUsersFromPosts = postAuthorRel.buildWith(
|
const selectUsersFromPosts = kysely
|
||||||
posts,
|
.selectFrom(posts.name)
|
||||||
kysely.selectFrom(posts.name),
|
.select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author"));
|
||||||
jsonFrom,
|
|
||||||
"author"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(selectUsersFromPosts.compile().sql).toBe(
|
expect(selectUsersFromPosts.compile().sql).toBe(
|
||||||
'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
|
'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
|
||||||
);
|
);
|
||||||
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
|
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
|
||||||
const postObj = { id: 1, title: "test" };
|
const postObj = { id: 1, title: "test" };
|
||||||
@@ -315,20 +308,16 @@ describe("Relations", async () => {
|
|||||||
- select: users.*
|
- select: users.*
|
||||||
- cardinality: 1
|
- cardinality: 1
|
||||||
*/
|
*/
|
||||||
const selectCategoriesFromPosts = postCategoriesRel.buildWith(
|
const selectCategoriesFromPosts = kysely
|
||||||
posts,
|
.selectFrom(posts.name)
|
||||||
kysely.selectFrom(posts.name),
|
.select((eb) => postCategoriesRel.buildWith(posts)(eb).as("categories"));
|
||||||
jsonFrom
|
|
||||||
);
|
|
||||||
expect(selectCategoriesFromPosts.compile().sql).toBe(
|
expect(selectCategoriesFromPosts.compile().sql).toBe(
|
||||||
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
|
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectPostsFromCategories = postCategoriesRel.buildWith(
|
const selectPostsFromCategories = kysely
|
||||||
categories,
|
.selectFrom(categories.name)
|
||||||
kysely.selectFrom(categories.name),
|
.select((eb) => postCategoriesRel.buildWith(categories)(eb).as("posts"));
|
||||||
jsonFrom
|
|
||||||
);
|
|
||||||
expect(selectPostsFromCategories.compile().sql).toBe(
|
expect(selectPostsFromCategories.compile().sql).toBe(
|
||||||
'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"'
|
'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import type { EventManager } from "../../../src/core/events";
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
RelationMutator,
|
RelationMutator,
|
||||||
TextField
|
TextField
|
||||||
} from "../../../src/data";
|
} from "../../../src/data";
|
||||||
|
import * as proto from "../../../src/data/prototype";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
@@ -22,7 +24,7 @@ describe("[data] Mutator (base)", async () => {
|
|||||||
new TextField("hidden", { hidden: true }),
|
new TextField("hidden", { hidden: true }),
|
||||||
new TextField("not_fillable", { fillable: false })
|
new TextField("not_fillable", { fillable: false })
|
||||||
]);
|
]);
|
||||||
const em = new EntityManager([entity], dummyConnection);
|
const em = new EntityManager<any>([entity], dummyConnection);
|
||||||
await em.schema().sync({ force: true });
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
const payload = { label: "item 1", count: 1 };
|
const payload = { label: "item 1", count: 1 };
|
||||||
@@ -61,7 +63,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
|||||||
const posts = new Entity("posts", [new TextField("title")]);
|
const posts = new Entity("posts", [new TextField("title")]);
|
||||||
const users = new Entity("users", [new TextField("username")]);
|
const users = new Entity("users", [new TextField("username")]);
|
||||||
const relations = [new ManyToOneRelation(posts, users)];
|
const relations = [new ManyToOneRelation(posts, users)];
|
||||||
const em = new EntityManager([posts, users], dummyConnection, relations);
|
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
|
||||||
await em.schema().sync({ force: true });
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
test("RelationMutator", async () => {
|
test("RelationMutator", async () => {
|
||||||
@@ -83,14 +85,12 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
|||||||
|
|
||||||
// persisting reference should ...
|
// persisting reference should ...
|
||||||
expect(
|
expect(
|
||||||
postRelMutator.persistReference(relations[0], "users", {
|
postRelMutator.persistReference(relations[0]!, "users", {
|
||||||
$set: { id: userData.data.id }
|
$set: { id: userData.data.id }
|
||||||
})
|
})
|
||||||
).resolves.toEqual(["users_id", userData.data.id]);
|
).resolves.toEqual(["users_id", userData.data.id]);
|
||||||
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
|
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
const userRelMutator = new RelationMutator(users, em);
|
const userRelMutator = new RelationMutator(users, em);
|
||||||
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
|
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
|
||||||
});
|
});
|
||||||
@@ -99,7 +99,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
|||||||
expect(
|
expect(
|
||||||
em.mutator(posts).insertOne({
|
em.mutator(posts).insertOne({
|
||||||
title: "post1",
|
title: "post1",
|
||||||
users_id: 1 // user does not exist yet
|
users_id: 100 // user does not exist yet
|
||||||
})
|
})
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
|
|||||||
const users = new Entity("users", [new TextField("username")]);
|
const users = new Entity("users", [new TextField("username")]);
|
||||||
const settings = new Entity("settings", [new TextField("theme")]);
|
const settings = new Entity("settings", [new TextField("theme")]);
|
||||||
const relations = [new OneToOneRelation(users, settings)];
|
const relations = [new OneToOneRelation(users, settings)];
|
||||||
const em = new EntityManager([users, settings], dummyConnection, relations);
|
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
|
||||||
await em.schema().sync({ force: true });
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
test("insertOne: missing ref", async () => {
|
test("insertOne: missing ref", async () => {
|
||||||
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
|
|||||||
|
|
||||||
describe("[data] Mutator (Events)", async () => {
|
describe("[data] Mutator (Events)", async () => {
|
||||||
const entity = new Entity("test", [new TextField("label")]);
|
const entity = new Entity("test", [new TextField("label")]);
|
||||||
const em = new EntityManager([entity], dummyConnection);
|
const em = new EntityManager<any>([entity], dummyConnection);
|
||||||
await em.schema().sync({ force: true });
|
await em.schema().sync({ force: true });
|
||||||
const events = new Map<string, any>();
|
const events = new Map<string, any>();
|
||||||
|
|
||||||
@@ -299,4 +299,71 @@ describe("[data] Mutator (Events)", async () => {
|
|||||||
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
|
||||||
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("insertOne event return is respected", async () => {
|
||||||
|
const posts = proto.entity("posts", {
|
||||||
|
title: proto.text(),
|
||||||
|
views: proto.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
const conn = getDummyConnection();
|
||||||
|
const em = new EntityManager([posts], conn.dummyConnection);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
const emgr = em.emgr as EventManager<any>;
|
||||||
|
|
||||||
|
emgr.onEvent(
|
||||||
|
// @ts-ignore
|
||||||
|
EntityManager.Events.MutatorInsertBefore,
|
||||||
|
async (event) => {
|
||||||
|
return {
|
||||||
|
...event.params.data,
|
||||||
|
views: 2
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutator = em.mutator("posts");
|
||||||
|
const result = await mutator.insertOne({ title: "test", views: 1 });
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
id: 1,
|
||||||
|
title: "test",
|
||||||
|
views: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateOne event return is respected", async () => {
|
||||||
|
const posts = proto.entity("posts", {
|
||||||
|
title: proto.text(),
|
||||||
|
views: proto.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
const conn = getDummyConnection();
|
||||||
|
const em = new EntityManager([posts], conn.dummyConnection);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
const emgr = em.emgr as EventManager<any>;
|
||||||
|
|
||||||
|
emgr.onEvent(
|
||||||
|
// @ts-ignore
|
||||||
|
EntityManager.Events.MutatorUpdateBefore,
|
||||||
|
async (event) => {
|
||||||
|
return {
|
||||||
|
...event.params.data,
|
||||||
|
views: event.params.data.views + 1
|
||||||
|
};
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutator = em.mutator("posts");
|
||||||
|
const created = await mutator.insertOne({ title: "test", views: 1 });
|
||||||
|
const result = await mutator.updateOne(created.data.id, { views: 2 });
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
id: 1,
|
||||||
|
title: "test",
|
||||||
|
views: 3
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
// @ts-ignore
|
|
||||||
import { Perf } from "@bknd/core/utils";
|
|
||||||
import type { Kysely, Transaction } from "kysely";
|
import type { Kysely, Transaction } from "kysely";
|
||||||
|
import { Perf } from "../../../src/core/utils";
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -24,7 +23,7 @@ async function sleep(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("[Repository]", async () => {
|
describe("[Repository]", async () => {
|
||||||
test("bulk", async () => {
|
test.skip("bulk", async () => {
|
||||||
//const connection = dummyConnection;
|
//const connection = dummyConnection;
|
||||||
//const connection = getLocalLibsqlConnection();
|
//const connection = getLocalLibsqlConnection();
|
||||||
const credentials = null as any; // @todo: determine what to do here
|
const credentials = null as any; // @todo: determine what to do here
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import { _jsonp } from "../../../src/core/utils";
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -8,19 +9,56 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
WithBuilder
|
WithBuilder
|
||||||
} from "../../../src/data";
|
} from "../../../src/data";
|
||||||
|
import * as proto from "../../../src/data/prototype";
|
||||||
|
import { compileQb, prettyPrintQb, schemaToEm } from "../../helper";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
afterAll(afterAllCleanup);
|
|
||||||
|
|
||||||
describe("[data] WithBuilder", async () => {
|
describe("[data] WithBuilder", async () => {
|
||||||
|
test("validate withs", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
posts: proto.entity("posts", {}),
|
||||||
|
users: proto.entity("users", {}),
|
||||||
|
media: proto.entity("media", {})
|
||||||
|
},
|
||||||
|
({ relation }, { posts, users, media }) => {
|
||||||
|
relation(posts).manyToOne(users);
|
||||||
|
relation(users).polyToOne(media, { mappedBy: "avatar" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const em = schemaToEm(schema);
|
||||||
|
|
||||||
|
expect(WithBuilder.validateWiths(em, "posts", undefined)).toBe(0);
|
||||||
|
expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0);
|
||||||
|
expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1);
|
||||||
|
expect(
|
||||||
|
WithBuilder.validateWiths(em, "posts", {
|
||||||
|
users: {
|
||||||
|
with: { avatar: {} }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toBe(2);
|
||||||
|
expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
WithBuilder.validateWiths(em, "posts", {
|
||||||
|
users: {
|
||||||
|
with: { glibberish: {} }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test("missing relation", async () => {
|
test("missing relation", async () => {
|
||||||
const users = new Entity("users", [new TextField("username")]);
|
const users = new Entity("users", [new TextField("username")]);
|
||||||
const em = new EntityManager([users], dummyConnection);
|
const em = new EntityManager([users], dummyConnection);
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
|
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
|
||||||
).toThrow('Relation "posts" not found');
|
posts: {}
|
||||||
|
})
|
||||||
|
).toThrow('Relation "users<>posts" not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("addClause: ManyToOne", async () => {
|
test("addClause: ManyToOne", async () => {
|
||||||
@@ -29,36 +67,39 @@ describe("[data] WithBuilder", async () => {
|
|||||||
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
|
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
|
||||||
const em = new EntityManager([users, posts], dummyConnection, relations);
|
const em = new EntityManager([users, posts], dummyConnection, relations);
|
||||||
|
|
||||||
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
|
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
|
||||||
"posts"
|
posts: {}
|
||||||
]);
|
});
|
||||||
|
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
|
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toBe(
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"'
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) as agg) as "posts" from "users"'
|
||||||
);
|
);
|
||||||
expect(res.parameters).toEqual([5]);
|
expect(res.parameters).toEqual([10, 0]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
em,
|
em,
|
||||||
em.connection.kysely.selectFrom("posts"),
|
em.connection.kysely.selectFrom("posts"),
|
||||||
posts, // @todo: try with "users", it gives output!
|
posts, // @todo: try with "users", it gives output!
|
||||||
["author"]
|
{
|
||||||
|
author: {}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
|
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"'
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"'
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1]);
|
expect(res2.parameters).toEqual([1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("test with empty join", async () => {
|
test("test with empty join", async () => {
|
||||||
|
const em = new EntityManager([], dummyConnection);
|
||||||
const qb = { qb: 1 } as any;
|
const qb = { qb: 1 } as any;
|
||||||
|
|
||||||
expect(WithBuilder.addClause(null as any, qb, null as any, [])).toBe(qb);
|
expect(WithBuilder.addClause(em, qb, null as any, {})).toBe(qb);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("test manytomany", async () => {
|
test("test manytomany", async () => {
|
||||||
@@ -89,7 +130,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
|
|
||||||
//console.log((await em.repository().findMany("posts_categories")).result);
|
//console.log((await em.repository().findMany("posts_categories")).result);
|
||||||
|
|
||||||
const res = await em.repository(posts).findMany({ with: ["categories"] });
|
const res = await em.repository(posts).findMany({ with: { categories: {} } });
|
||||||
|
|
||||||
expect(res.data).toEqual([
|
expect(res.data).toEqual([
|
||||||
{
|
{
|
||||||
@@ -107,7 +148,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const res2 = await em.repository(categories).findMany({ with: ["posts"] });
|
const res2 = await em.repository(categories).findMany({ with: { posts: {} } });
|
||||||
|
|
||||||
//console.log(res2.sql, res2.data);
|
//console.log(res2.sql, res2.data);
|
||||||
|
|
||||||
@@ -121,8 +162,8 @@ describe("[data] WithBuilder", async () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
label: "beauty",
|
label: "beauty",
|
||||||
posts: [
|
posts: [
|
||||||
{ id: 2, title: "beauty post" },
|
{ id: 1, title: "fashion post" },
|
||||||
{ id: 1, title: "fashion post" }
|
{ id: 2, title: "beauty post" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,25 +191,25 @@ describe("[data] WithBuilder", async () => {
|
|||||||
em,
|
em,
|
||||||
em.connection.kysely.selectFrom("categories"),
|
em.connection.kysely.selectFrom("categories"),
|
||||||
categories,
|
categories,
|
||||||
["single"]
|
{ single: {} }
|
||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"'
|
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"'
|
||||||
);
|
);
|
||||||
expect(res.parameters).toEqual(["categories.single", 1]);
|
expect(res.parameters).toEqual(["categories.single", 1, 0]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
em,
|
em,
|
||||||
em.connection.kysely.selectFrom("categories"),
|
em.connection.kysely.selectFrom("categories"),
|
||||||
categories,
|
categories,
|
||||||
["multiple"]
|
{ multiple: {} }
|
||||||
);
|
);
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toBe(
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"'
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"'
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual(["categories.multiple", 5]);
|
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
/*test("test manytoone", async () => {
|
/*test("test manytoone", async () => {
|
||||||
@@ -192,4 +233,205 @@ describe("[data] WithBuilder", async () => {
|
|||||||
const res = await em.repository().findMany("posts", { join: ["author"] });
|
const res = await em.repository().findMany("posts", { join: ["author"] });
|
||||||
console.log(res.sql, res.parameters, res.result);
|
console.log(res.sql, res.parameters, res.result);
|
||||||
});*/
|
});*/
|
||||||
|
|
||||||
|
describe("recursive", () => {
|
||||||
|
test("compiles with singles", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
posts: proto.entity("posts", {}),
|
||||||
|
users: proto.entity("users", {
|
||||||
|
username: proto.text()
|
||||||
|
}),
|
||||||
|
media: proto.entity("media", {
|
||||||
|
path: proto.text()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
({ relation }, { posts, users, media }) => {
|
||||||
|
relation(posts).manyToOne(users);
|
||||||
|
relation(users).polyToOne(media, { mappedBy: "avatar" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const em = schemaToEm(schema);
|
||||||
|
|
||||||
|
const qb = WithBuilder.addClause(
|
||||||
|
em,
|
||||||
|
em.connection.kysely.selectFrom("posts"),
|
||||||
|
schema.entities.posts,
|
||||||
|
{
|
||||||
|
users: {
|
||||||
|
limit: 5, // ignored
|
||||||
|
select: ["id", "username"],
|
||||||
|
sort: { by: "username", dir: "asc" },
|
||||||
|
with: {
|
||||||
|
avatar: {
|
||||||
|
select: ["id", "path"],
|
||||||
|
limit: 2 // ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//prettyPrintQb(qb);
|
||||||
|
expect(qb.compile().sql).toBe(
|
||||||
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"'
|
||||||
|
);
|
||||||
|
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compiles with many", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
posts: proto.entity("posts", {}),
|
||||||
|
comments: proto.entity("comments", {}),
|
||||||
|
users: proto.entity("users", {
|
||||||
|
username: proto.text()
|
||||||
|
}),
|
||||||
|
media: proto.entity("media", {
|
||||||
|
path: proto.text()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
({ relation }, { posts, comments, users, media }) => {
|
||||||
|
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
|
||||||
|
relation(users).polyToOne(media, { mappedBy: "avatar" });
|
||||||
|
relation(comments).manyToOne(posts).manyToOne(users);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const em = schemaToEm(schema);
|
||||||
|
|
||||||
|
const qb = WithBuilder.addClause(
|
||||||
|
em,
|
||||||
|
em.connection.kysely.selectFrom("posts"),
|
||||||
|
schema.entities.posts,
|
||||||
|
{
|
||||||
|
comments: {
|
||||||
|
limit: 12,
|
||||||
|
with: {
|
||||||
|
users: {
|
||||||
|
select: ["username"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(qb.compile().sql).toBe(
|
||||||
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"'
|
||||||
|
);
|
||||||
|
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns correct result", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
posts: proto.entity("posts", {
|
||||||
|
title: proto.text()
|
||||||
|
}),
|
||||||
|
comments: proto.entity("comments", {
|
||||||
|
content: proto.text()
|
||||||
|
}),
|
||||||
|
users: proto.entity("users", {
|
||||||
|
username: proto.text()
|
||||||
|
}),
|
||||||
|
media: proto.entity("media", {
|
||||||
|
path: proto.text()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
({ relation }, { posts, comments, users, media }) => {
|
||||||
|
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
|
||||||
|
relation(users).polyToOne(media, { mappedBy: "avatar" });
|
||||||
|
relation(comments).manyToOne(posts).manyToOne(users);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const em = schemaToEm(schema);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
// add data
|
||||||
|
await em.mutator("users").insertMany([{ username: "user1" }, { username: "user2" }]);
|
||||||
|
await em.mutator("posts").insertMany([
|
||||||
|
{ title: "post1", users_id: 1 },
|
||||||
|
{ title: "post2", users_id: 1 },
|
||||||
|
{ title: "post3", users_id: 2 }
|
||||||
|
]);
|
||||||
|
await em.mutator("comments").insertMany([
|
||||||
|
{ content: "comment1", posts_id: 1, users_id: 1 },
|
||||||
|
{ content: "comment1-1", posts_id: 1, users_id: 1 },
|
||||||
|
{ content: "comment2", posts_id: 1, users_id: 2 },
|
||||||
|
{ content: "comment3", posts_id: 2, users_id: 1 },
|
||||||
|
{ content: "comment4", posts_id: 2, users_id: 2 },
|
||||||
|
{ content: "comment5", posts_id: 3, users_id: 1 },
|
||||||
|
{ content: "comment6", posts_id: 3, users_id: 2 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await em.repo("posts").findMany({
|
||||||
|
select: ["title"],
|
||||||
|
with: {
|
||||||
|
comments: {
|
||||||
|
limit: 2,
|
||||||
|
select: ["content"],
|
||||||
|
with: {
|
||||||
|
users: {
|
||||||
|
select: ["username"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data).toEqual([
|
||||||
|
{
|
||||||
|
title: "post1",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
content: "comment1",
|
||||||
|
users: {
|
||||||
|
username: "user1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "comment1-1",
|
||||||
|
users: {
|
||||||
|
username: "user1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "post2",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
content: "comment3",
|
||||||
|
users: {
|
||||||
|
username: "user1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "comment4",
|
||||||
|
users: {
|
||||||
|
username: "user2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "post3",
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
content: "comment5",
|
||||||
|
users: {
|
||||||
|
username: "user1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "comment6",
|
||||||
|
users: {
|
||||||
|
username: "user2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
//console.log(_jsonp(result.data));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ describe("[data] EnumField", async () => {
|
|||||||
{ options: options(["a", "b", "c"]) }
|
{ options: options(["a", "b", "c"]) }
|
||||||
);
|
);
|
||||||
|
|
||||||
test("yields if no options", async () => {
|
|
||||||
expect(() => new EnumField("test", { options: options([]) })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("yields if default value is not a valid option", async () => {
|
test("yields if default value is not a valid option", async () => {
|
||||||
expect(
|
expect(
|
||||||
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })
|
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ describe("[data] Field", async () => {
|
|||||||
|
|
||||||
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
|
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
|
||||||
|
|
||||||
test.only("default config", async () => {
|
test("default config", async () => {
|
||||||
const field = new FieldSpec("test");
|
|
||||||
const config = Default(baseFieldConfigSchema, {});
|
const config = Default(baseFieldConfigSchema, {});
|
||||||
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
|
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
|
||||||
console.log("config", new TextField("test", { required: true }).toJSON());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("transformPersist (specific)", async () => {
|
test("transformPersist (specific)", async () => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("[data] JsonField", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getValue", async () => {
|
test("getValue", async () => {
|
||||||
expect(field.getValue({ test: 1 }, "form")).toBe('{"test":1}');
|
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
|
||||||
expect(field.getValue("string", "form")).toBe('"string"');
|
expect(field.getValue("string", "form")).toBe('"string"');
|
||||||
expect(field.getValue(1, "form")).toBe("1");
|
expect(field.getValue(1, "form")).toBe("1");
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ describe("[data] EntityRelation", async () => {
|
|||||||
|
|
||||||
it("required", async () => {
|
it("required", async () => {
|
||||||
const relation1 = new TestEntityRelation();
|
const relation1 = new TestEntityRelation();
|
||||||
expect(relation1.config.required).toBe(false);
|
expect(relation1.required).toBe(false);
|
||||||
|
|
||||||
const relation2 = new TestEntityRelation({ required: true });
|
const relation2 = new TestEntityRelation({ required: true });
|
||||||
expect(relation2.config.required).toBe(true);
|
expect(relation2.required).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { type Static, Type, _jsonp } from "../../src/core/utils";
|
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
|
||||||
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
||||||
|
|
||||||
/*beforeAll(disableConsoleLog);
|
/*beforeAll(disableConsoleLog);
|
||||||
@@ -232,7 +232,9 @@ describe("Flow tests", async () => {
|
|||||||
).toEqual(["second", "fourth"]);
|
).toEqual(["second", "fourth"]);
|
||||||
|
|
||||||
const execution = back.createExecution();
|
const execution = back.createExecution();
|
||||||
expect(execution.start()).rejects.toThrow();
|
withDisabledConsole(async () => {
|
||||||
|
expect(execution.start()).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Flow with back step: enough retries", async () => {
|
test("Flow with back step: enough retries", async () => {
|
||||||
|
|||||||
+23
-3
@@ -1,7 +1,9 @@
|
|||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import type { SqliteDatabase } from "kysely";
|
import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
|
||||||
import Database from "libsql";
|
import Database from "libsql";
|
||||||
import { SqliteLocalConnection } from "../src/data";
|
import { format as sqlFormat } from "sql-formatter";
|
||||||
|
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
|
||||||
|
import type { em as protoEm } from "../src/data/prototype";
|
||||||
|
|
||||||
export function getDummyDatabase(memory: boolean = true): {
|
export function getDummyDatabase(memory: boolean = true): {
|
||||||
dummyDb: SqliteDatabase;
|
dummyDb: SqliteDatabase;
|
||||||
@@ -40,7 +42,7 @@ const _oldConsoles = {
|
|||||||
error: console.error
|
error: console.error
|
||||||
};
|
};
|
||||||
|
|
||||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||||
severities.forEach((severity) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
@@ -51,3 +53,21 @@ export function enableConsoleLog() {
|
|||||||
console[severity as ConsoleSeverity] = fn;
|
console[severity as ConsoleSeverity] = fn;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||||
|
const { sql, parameters } = qb.compile();
|
||||||
|
return { sql, parameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||||
|
const { sql, parameters } = qb.compile();
|
||||||
|
console.log("$", sqlFormat(sql), "\n[params]", parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
|
||||||
|
const connection = conn ? conn : getDummyConnection().dummyConnection;
|
||||||
|
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assetsPath = `${import.meta.dir}/_assets`;
|
||||||
|
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { App, createApp } from "../../src";
|
||||||
|
import type { AuthResponse } from "../../src/auth";
|
||||||
|
import { auth } from "../../src/auth/middlewares";
|
||||||
|
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
const roles = {
|
||||||
|
sloppy: {
|
||||||
|
guest: {
|
||||||
|
permissions: [
|
||||||
|
"system.access.admin",
|
||||||
|
"system.schema.read",
|
||||||
|
"system.access.api",
|
||||||
|
"system.config.read",
|
||||||
|
"data.entity.read"
|
||||||
|
],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
strict: {
|
||||||
|
guest: {
|
||||||
|
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const configs = {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
entity_name: "users",
|
||||||
|
jwt: {
|
||||||
|
secret: secureRandomString(20),
|
||||||
|
issuer: randomString(10)
|
||||||
|
},
|
||||||
|
roles: roles.strict,
|
||||||
|
guard: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
normal: {
|
||||||
|
email: "normal@bknd.io",
|
||||||
|
password: "12345678"
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
email: "admin@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthApp() {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: configs.auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppFirstBoot,
|
||||||
|
async () => {
|
||||||
|
await app.createUser(configs.users.normal);
|
||||||
|
await app.createUser(configs.users.admin);
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(r: Response, name: string) {
|
||||||
|
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
|
||||||
|
if (!cookies) return;
|
||||||
|
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
|
||||||
|
if (!cookie) return;
|
||||||
|
return cookie.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
|
||||||
|
function headers(token?: string, additional?: Record<string, string>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
return {
|
||||||
|
cookie: `auth=${token};`,
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function body(obj?: Record<string, any>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const key in obj) {
|
||||||
|
formData.append(key, obj[key]);
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: async (
|
||||||
|
user: any
|
||||||
|
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(),
|
||||||
|
body: body(user)
|
||||||
|
})) as Response;
|
||||||
|
|
||||||
|
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||||
|
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers(token)
|
||||||
|
})) as Response;
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("integration auth", () => {
|
||||||
|
it("should create users on boot", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const { data: users } = await app.em.repository("users").findMany();
|
||||||
|
expect(users.length).toBe(2);
|
||||||
|
expect(users[0].email).toBe(configs.users.normal.email);
|
||||||
|
expect(users[1].email).toBe(configs.users.admin.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with API", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// login api
|
||||||
|
const { data } = await $fns.login(configs.users.normal);
|
||||||
|
const me = await $fns.me(data.token);
|
||||||
|
|
||||||
|
expect(data.user.email).toBe(me.user.email);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// expect no user with no token
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
|
||||||
|
// expect no user with invalid token
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with form and cookie", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app, "cookie");
|
||||||
|
|
||||||
|
const { res, data: token } = await $fns.login(configs.users.normal);
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
expect(res.status).toBe(302); // because it redirects
|
||||||
|
|
||||||
|
// test cookie jwt interchangability
|
||||||
|
{
|
||||||
|
// expect token to not work as-is for api endpoints
|
||||||
|
expect(await fns(app).me(token)).toEqual({ user: null as any });
|
||||||
|
// hono adds an additional segment to cookies
|
||||||
|
const apified_token = token.split(".").slice(0, -1).join(".");
|
||||||
|
// now it should work
|
||||||
|
// @todo: maybe add a config to don't allow re-use?
|
||||||
|
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test cookie with me endpoint
|
||||||
|
{
|
||||||
|
const me = await $fns.me(token);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// check with invalid & empty
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for permissions", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
await withDisabledConsole(async () => {
|
||||||
|
const res = await app.server.request("/api/system/schema");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context is exclusive", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
app.server.get("/get", auth(), async (c) => {
|
||||||
|
return c.json({
|
||||||
|
user: c.get("auth").user ?? null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.server.get("/wait", auth(), async (c) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await $fns.login(configs.users.normal);
|
||||||
|
const me = await $fns.me(data.token);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
app.server.request("/wait", {
|
||||||
|
headers: { Authorization: `Bearer ${data.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const res = await app.server.request("/get");
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.user).toBe(null);
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { createApp } from "../../src";
|
||||||
|
import { Api } from "../../src/Api";
|
||||||
|
|
||||||
|
describe("integration config", () => {
|
||||||
|
it("should create an entity", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
await app.build();
|
||||||
|
const api = new Api({
|
||||||
|
host: "http://localhost",
|
||||||
|
fetcher: app.server.request as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
// create entity
|
||||||
|
await api.system.addConfig("data", "entities.posts", {
|
||||||
|
name: "posts",
|
||||||
|
config: { sort_field: "id", sort_dir: "asc" },
|
||||||
|
fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
|
||||||
|
type: "regular"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.em.entities.map((e) => e.name)).toContain("posts");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,56 +1,96 @@
|
|||||||
import { describe, test } from "bun:test";
|
/// <reference types="@types/bun" />
|
||||||
import { Hono } from "hono";
|
|
||||||
import { Guard } from "../../src/auth";
|
|
||||||
import { EventManager } from "../../src/core/events";
|
|
||||||
import { EntityManager } from "../../src/data";
|
|
||||||
import { AppMedia } from "../../src/media/AppMedia";
|
|
||||||
import { MediaController } from "../../src/media/api/MediaController";
|
|
||||||
import { getDummyConnection } from "../helper";
|
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { createApp, registries } from "../../src";
|
||||||
|
import { StorageLocalAdapter } from "../../src/adapter/node";
|
||||||
|
import { mergeObject, randomString } from "../../src/core/utils";
|
||||||
|
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||||
|
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
/**
|
beforeAll(() => {
|
||||||
* R2
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
* value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
|
});
|
||||||
* Node writefile
|
|
||||||
* data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | Stream,
|
|
||||||
*/
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
|
||||||
describe.skipIf(ALL_TESTS)("MediaController", () => {
|
|
||||||
test("..", async () => {
|
|
||||||
const ctx: any = {
|
|
||||||
em: new EntityManager([], dummyConnection, []),
|
|
||||||
guard: new Guard(),
|
|
||||||
emgr: new EventManager(),
|
|
||||||
server: new Hono()
|
|
||||||
};
|
|
||||||
|
|
||||||
const media = new AppMedia(
|
const path = `${assetsPath}/image.png`;
|
||||||
// @ts-ignore
|
|
||||||
{
|
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||||
enabled: true,
|
const app = createApp({
|
||||||
adapter: {
|
initialConfig: {
|
||||||
type: "s3",
|
media: mergeObject(
|
||||||
config: {
|
{
|
||||||
access_key: process.env.R2_ACCESS_KEY as string,
|
enabled: true,
|
||||||
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
|
adapter: {
|
||||||
url: process.env.R2_URL as string
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: assetsTmpPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
mediaOverride
|
||||||
ctx
|
)
|
||||||
);
|
}
|
||||||
await media.build();
|
});
|
||||||
const app = new MediaController(media).getController();
|
|
||||||
|
|
||||||
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
|
await app.build();
|
||||||
console.log("file", file);
|
return app;
|
||||||
const form = new FormData();
|
}
|
||||||
form.append("file", file);
|
|
||||||
|
|
||||||
await app.request("/upload/test.png", {
|
function makeName(ext: string) {
|
||||||
|
return randomString(10) + "." + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("MediaController", () => {
|
||||||
|
test("accepts direct", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: file
|
body: file
|
||||||
});
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts form data", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits body", async () => {
|
||||||
|
const app = await makeApp({ storage: { body_max_size: 1 } });
|
||||||
|
|
||||||
|
const file = await Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { randomString } from "../../../src/core/utils";
|
|||||||
import { StorageCloudinaryAdapter } from "../../../src/media";
|
import { StorageCloudinaryAdapter } from "../../../src/media";
|
||||||
|
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
|
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
|
||||||
const {
|
const {
|
||||||
CLOUDINARY_CLOUD_NAME,
|
CLOUDINARY_CLOUD_NAME,
|
||||||
CLOUDINARY_API_KEY,
|
CLOUDINARY_API_KEY,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
|
|||||||
|
|
||||||
test("puts an object", async () => {
|
test("puts an object", async () => {
|
||||||
objects = (await adapter.listObjects()).length;
|
objects = (await adapter.listObjects()).length;
|
||||||
expect(await adapter.putObject(filename, await file.arrayBuffer())).toBeString();
|
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists objects", async () => {
|
test("lists objects", async () => {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { randomString } from "../../../src/core/utils";
|
|||||||
import { StorageS3Adapter } from "../../../src/media";
|
import { StorageS3Adapter } from "../../../src/media";
|
||||||
|
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
|
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
|
||||||
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
|
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
|
||||||
dotenvOutput.parsed!;
|
dotenvOutput.parsed!;
|
||||||
|
|
||||||
// @todo: mock r2/s3 responses for faster tests
|
// @todo: mock r2/s3 responses for faster tests
|
||||||
const ALL_TESTS = process.env.ALL_TESTS;
|
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||||
|
|
||||||
describe("Storage", async () => {
|
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
|
||||||
console.log("ALL_TESTS", process.env.ALL_TESTS);
|
console.log("ALL_TESTS", process.env.ALL_TESTS);
|
||||||
const versions = [
|
const versions = [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import * as large from "../../src/media/storage/mime-types";
|
||||||
|
import * as tiny from "../../src/media/storage/mime-types-tiny";
|
||||||
|
|
||||||
|
describe("media/mime-types", () => {
|
||||||
|
test("tiny resolves", () => {
|
||||||
|
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
|
||||||
|
|
||||||
|
for (const [ext, mime] of tests) {
|
||||||
|
expect(tiny.guess(ext)).toBe(mime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all tiny resolves to large", () => {
|
||||||
|
for (const [ext, mime] of Object.entries(tiny.M)) {
|
||||||
|
expect(large.guessMimeType("." + ext)).toBe(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, exts] of Object.entries(tiny.Q)) {
|
||||||
|
for (const ext of exts) {
|
||||||
|
const ex = `${type}/${ext}`;
|
||||||
|
try {
|
||||||
|
expect(large.guessMimeType("." + ext)).toBe(ex);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Failed for ${ext}`, {
|
||||||
|
type,
|
||||||
|
exts,
|
||||||
|
ext,
|
||||||
|
expected: ex,
|
||||||
|
actual: large.guessMimeType("." + ext)
|
||||||
|
});
|
||||||
|
throw new Error(`Failed for ${ext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
|
import { createApp } from "../../src";
|
||||||
import { AuthController } from "../../src/auth/api/AuthController";
|
import { AuthController } from "../../src/auth/api/AuthController";
|
||||||
|
import { em, entity, make, text } from "../../src/data";
|
||||||
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||||
@@ -76,4 +78,87 @@ describe("AppAuth", () => {
|
|||||||
expect(users[0].email).toBe("some@body.com");
|
expect(users[0].email).toBe("some@body.com");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
|
// register custom route
|
||||||
|
app.server.get("/test", async (c) => c.text("test"));
|
||||||
|
|
||||||
|
// call a system api and then the custom route
|
||||||
|
await app.server.request("/api/system/ping");
|
||||||
|
await app.server.request("/test");
|
||||||
|
|
||||||
|
expect(spy.mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow additional user fields", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
entity_name: "users",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
users: entity("users", {
|
||||||
|
additional: text()
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const e = app.modules.em.entity("users");
|
||||||
|
const fields = e.fields.map((f) => f.name);
|
||||||
|
expect(e.type).toBe("system");
|
||||||
|
expect(fields).toContain("additional");
|
||||||
|
expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure user field configs is always correct", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
users: entity("users", {
|
||||||
|
strategy: text({
|
||||||
|
fillable: true,
|
||||||
|
hidden: false
|
||||||
|
}),
|
||||||
|
strategy_value: text({
|
||||||
|
fillable: true,
|
||||||
|
hidden: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const users = app.em.entity("users");
|
||||||
|
const props = ["hidden", "fillable", "required"];
|
||||||
|
|
||||||
|
for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) {
|
||||||
|
const authField = make(name, _authFieldProto as any);
|
||||||
|
const field = users.field(name)!;
|
||||||
|
for (const prop of props) {
|
||||||
|
expect(field.config[prop]).toBe(authField.config[prop]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
import { describe } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createApp, registries } from "../../src";
|
||||||
|
import { em, entity, text } from "../../src/data";
|
||||||
|
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/modules";
|
import { AppMedia } from "../../src/modules";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
|
|
||||||
describe("AppMedia", () => {
|
describe("AppMedia", () => {
|
||||||
moduleTestSuite(AppMedia);
|
moduleTestSuite(AppMedia);
|
||||||
|
|
||||||
|
test("should allow additional fields", async () => {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
media: {
|
||||||
|
entity_name: "media",
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
media: entity("media", {
|
||||||
|
additional: text()
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const e = app.modules.em.entity("media");
|
||||||
|
const fields = e.fields.map((f) => f.name);
|
||||||
|
expect(e.type).toBe("system");
|
||||||
|
expect(fields).toContain("additional");
|
||||||
|
expect(fields).toEqual([
|
||||||
|
"id",
|
||||||
|
"additional",
|
||||||
|
"path",
|
||||||
|
"folder",
|
||||||
|
"mime_type",
|
||||||
|
"size",
|
||||||
|
"scope",
|
||||||
|
"etag",
|
||||||
|
"modified_at",
|
||||||
|
"reference",
|
||||||
|
"entity_id",
|
||||||
|
"metadata"
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { type TSchema, Type, stripMark } from "../../src/core/utils";
|
||||||
|
import { EntityManager, em, entity, index, text } from "../../src/data";
|
||||||
|
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||||
|
import { Module } from "../../src/modules/Module";
|
||||||
|
|
||||||
|
function createModule<Schema extends TSchema>(schema: Schema) {
|
||||||
|
class TestModule extends Module<typeof schema> {
|
||||||
|
getSchema() {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
useForceParse() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TestModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Module", async () => {
|
||||||
|
describe("basic", () => {
|
||||||
|
test("listener", async () => {
|
||||||
|
let result: any;
|
||||||
|
|
||||||
|
const module = createModule(Type.Object({ a: Type.String() }));
|
||||||
|
const m = new module({ a: "test" });
|
||||||
|
|
||||||
|
await m.schema().set({ a: "test2" });
|
||||||
|
m.setListener(async (c) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
result = stripMark(c);
|
||||||
|
});
|
||||||
|
await m.schema().set({ a: "test3" });
|
||||||
|
expect(result).toEqual({ a: "test3" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("db schema", () => {
|
||||||
|
class M extends Module {
|
||||||
|
override getSchema() {
|
||||||
|
return Type.Object({});
|
||||||
|
}
|
||||||
|
|
||||||
|
prt = {
|
||||||
|
ensureEntity: this.ensureEntity.bind(this),
|
||||||
|
ensureIndex: this.ensureIndex.bind(this),
|
||||||
|
ensureSchema: this.ensureSchema.bind(this)
|
||||||
|
};
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.ctx.em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(_em: ReturnType<typeof em>) {
|
||||||
|
const em = new EntityManager(
|
||||||
|
Object.values(_em.entities),
|
||||||
|
new DummyConnection(),
|
||||||
|
_em.relations,
|
||||||
|
_em.indices
|
||||||
|
);
|
||||||
|
return new M({} as any, { em, flags: Module.ctx_flags } as any);
|
||||||
|
}
|
||||||
|
function flat(_em: EntityManager) {
|
||||||
|
return {
|
||||||
|
entities: _em.entities.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
fields: e.fields.map((f) => f.name),
|
||||||
|
type: e.type
|
||||||
|
})),
|
||||||
|
indices: _em.indices.map((i) => ({
|
||||||
|
name: i.name,
|
||||||
|
entity: i.entity.name,
|
||||||
|
fields: i.fields.map((f) => f.name),
|
||||||
|
unique: i.unique
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no change", () => {
|
||||||
|
const initial = em({});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(false);
|
||||||
|
|
||||||
|
expect(flat(make(initial).em)).toEqual({
|
||||||
|
entities: [],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init", () => {
|
||||||
|
const initial = em({
|
||||||
|
users: entity("u", {
|
||||||
|
name: text()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(false);
|
||||||
|
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure entity", () => {
|
||||||
|
const initial = em({
|
||||||
|
users: entity("u", {
|
||||||
|
name: text()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// this should add a new entity
|
||||||
|
m.prt.ensureEntity(
|
||||||
|
entity("p", {
|
||||||
|
title: text()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// this should only add the field "important"
|
||||||
|
m.prt.ensureEntity(
|
||||||
|
entity(
|
||||||
|
"u",
|
||||||
|
{
|
||||||
|
important: text()
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
"system"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(true);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name", "important"],
|
||||||
|
// ensured type must be present
|
||||||
|
type: "system"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "p",
|
||||||
|
fields: ["id", "title"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure index", () => {
|
||||||
|
const users = entity("u", {
|
||||||
|
name: text(),
|
||||||
|
title: text()
|
||||||
|
});
|
||||||
|
const initial = em({ users }, ({ index }, { users }) => {
|
||||||
|
index(users).on(["title"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
m.prt.ensureIndex(index(users).on(["name"]));
|
||||||
|
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(true);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name", "title"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
{
|
||||||
|
name: "idx_u_title",
|
||||||
|
entity: "u",
|
||||||
|
fields: ["title"],
|
||||||
|
unique: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "idx_u_name",
|
||||||
|
entity: "u",
|
||||||
|
fields: ["name"],
|
||||||
|
unique: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
|
||||||
|
import { entity, text } from "../../src/data";
|
||||||
|
import { Module } from "../../src/modules/Module";
|
||||||
|
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
||||||
|
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
||||||
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
|
describe("ModuleManager", async () => {
|
||||||
|
test("s1: no config, no build", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
|
const mm = new ModuleManager(dummyConnection);
|
||||||
|
|
||||||
|
// that is because no module is built
|
||||||
|
expect(mm.toJSON()).toEqual({ version: 0 } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s2: no config, build", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
|
const mm = new ModuleManager(dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
expect(mm.version()).toBe(CURRENT_VERSION);
|
||||||
|
expect(mm.built()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s3: config given, table exists, version matches", async () => {
|
||||||
|
const c = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const version = mm.version();
|
||||||
|
const configs = mm.configs();
|
||||||
|
const json = stripMark({
|
||||||
|
...configs,
|
||||||
|
data: {
|
||||||
|
...configs.data,
|
||||||
|
basepath: "/api/data2",
|
||||||
|
entities: {
|
||||||
|
test: entity("test", {
|
||||||
|
content: text()
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
|
const c2 = getDummyConnection();
|
||||||
|
const db = c2.dummyConnection.kysely;
|
||||||
|
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await mm2.build();
|
||||||
|
|
||||||
|
expect(json).toEqual(stripMark(mm2.configs()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s3.1: (fetch) config given, table exists, version matches", async () => {
|
||||||
|
const configs = getDefaultConfig();
|
||||||
|
const json = {
|
||||||
|
...configs,
|
||||||
|
data: {
|
||||||
|
...configs.data,
|
||||||
|
basepath: "/api/data2",
|
||||||
|
entities: {
|
||||||
|
test: entity("test", {
|
||||||
|
content: text()
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const db = dummyConnection.kysely;
|
||||||
|
const mm2 = new ModuleManager(dummyConnection);
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
// assume an initial version
|
||||||
|
await db.insertInto(TABLE_NAME).values({ type: "config", json: null, version: 1 }).execute();
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await mm2.build();
|
||||||
|
|
||||||
|
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
||||||
|
expect(mm2.configs().data.entities.test).toBeDefined();
|
||||||
|
expect(mm2.configs().data.entities.test.fields.content).toBeDefined();
|
||||||
|
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s4: config given, table exists, version outdated, migrate", async () => {
|
||||||
|
const c = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const version = mm.version();
|
||||||
|
const json = mm.configs();
|
||||||
|
|
||||||
|
const c2 = getDummyConnection();
|
||||||
|
const db = c2.dummyConnection.kysely;
|
||||||
|
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||||
|
initial: { version: version - 1, ...json }
|
||||||
|
});
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({ json: JSON.stringify(json), type: "config", version: CURRENT_VERSION - 1 })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await mm2.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s5: config given, table exists, version mismatch", async () => {
|
||||||
|
const c = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const version = mm.version();
|
||||||
|
const json = mm.configs();
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
|
const c2 = getDummyConnection();
|
||||||
|
const db = c2.dummyConnection.kysely;
|
||||||
|
|
||||||
|
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||||
|
initial: { version: version - 1, ...json }
|
||||||
|
});
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("s6: no config given, table exists, fetch", async () => {
|
||||||
|
const c = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const json = mm.configs();
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
|
const c2 = getDummyConnection();
|
||||||
|
const db = c2.dummyConnection.kysely;
|
||||||
|
|
||||||
|
const mm2 = new ModuleManager(c2.dummyConnection);
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...json,
|
||||||
|
data: {
|
||||||
|
...json.data,
|
||||||
|
basepath: "/api/data2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({ type: "config", json: JSON.stringify(config), version: CURRENT_VERSION })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// run without config given
|
||||||
|
await mm2.build();
|
||||||
|
|
||||||
|
expect(mm2.configs().data.basepath).toBe("/api/data2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blank app, modify config", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
|
const mm = new ModuleManager(dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const configs = stripMark(mm.configs());
|
||||||
|
|
||||||
|
expect(mm.configs().server.admin.color_scheme).toBeUndefined();
|
||||||
|
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
|
||||||
|
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
|
||||||
|
await mm.save();
|
||||||
|
|
||||||
|
expect(mm.configs().server.admin.color_scheme).toBe("dark");
|
||||||
|
expect(stripMark(mm.configs())).toEqual({
|
||||||
|
...configs,
|
||||||
|
server: {
|
||||||
|
...configs.server,
|
||||||
|
admin: {
|
||||||
|
...configs.server.admin,
|
||||||
|
color_scheme: "dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial config given", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
|
const partial = {
|
||||||
|
auth: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mm = new ModuleManager(dummyConnection, {
|
||||||
|
initial: partial
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
expect(mm.version()).toBe(CURRENT_VERSION);
|
||||||
|
expect(mm.built()).toBe(true);
|
||||||
|
expect(mm.configs().auth.enabled).toBe(true);
|
||||||
|
expect(mm.configs().data.entities.users).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial config given, but db version exists", async () => {
|
||||||
|
const c = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
const json = mm.configs();
|
||||||
|
|
||||||
|
const c2 = getDummyConnection();
|
||||||
|
const db = c2.dummyConnection.kysely;
|
||||||
|
|
||||||
|
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
auth: {
|
||||||
|
basepath: "/shouldnt/take/this"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm2.syncConfigTable();
|
||||||
|
const payload = {
|
||||||
|
...json,
|
||||||
|
auth: {
|
||||||
|
...json.auth,
|
||||||
|
enabled: true,
|
||||||
|
basepath: "/api/auth2"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await db
|
||||||
|
.insertInto(TABLE_NAME)
|
||||||
|
.values({
|
||||||
|
type: "config",
|
||||||
|
json: JSON.stringify(payload),
|
||||||
|
version: CURRENT_VERSION
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await mm2.build();
|
||||||
|
expect(mm2.configs().auth.basepath).toBe("/api/auth2");
|
||||||
|
});
|
||||||
|
|
||||||
|
// @todo: add tests for migrations (check "backup" and new version)
|
||||||
|
|
||||||
|
describe("revert", async () => {
|
||||||
|
const failingModuleSchema = Type.Object({
|
||||||
|
value: Type.Optional(Type.Number())
|
||||||
|
});
|
||||||
|
class FailingModule extends Module<typeof failingModuleSchema> {
|
||||||
|
getSchema() {
|
||||||
|
return failingModuleSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async build() {
|
||||||
|
//console.log("building FailingModule", this.config);
|
||||||
|
if (this.config.value < 0) {
|
||||||
|
throw new Error("value must be positive");
|
||||||
|
}
|
||||||
|
this.setBuilt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TestModuleManager extends ModuleManager {
|
||||||
|
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
|
||||||
|
super(...args);
|
||||||
|
const [, options] = args;
|
||||||
|
// @ts-ignore
|
||||||
|
const initial = options?.initial?.failing ?? {};
|
||||||
|
this.modules["failing"] = new FailingModule(initial, this.ctx());
|
||||||
|
this.modules["failing"].setListener(async (c) => {
|
||||||
|
// @ts-ignore
|
||||||
|
await this.onModuleConfigUpdated("failing", c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
|
||||||
|
afterEach(enableConsoleLog);
|
||||||
|
|
||||||
|
test("it builds", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
expect(mm).toBeDefined();
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.toJSON()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it accepts config", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.configs()["failing"].value).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it crashes on invalid", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: -1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(mm.build()).rejects.toThrow(/value must be positive/);
|
||||||
|
expect(mm.configs()["failing"].value).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it correctly accepts valid", async () => {
|
||||||
|
const mockOnUpdated = mock(() => null);
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
onUpdated: async () => {
|
||||||
|
mockOnUpdated();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
// @ts-ignore
|
||||||
|
const f = mm.mutateConfigSafe("failing");
|
||||||
|
|
||||||
|
expect(f.set({ value: 2 })).resolves.toBeDefined();
|
||||||
|
expect(mockOnUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it reverts on safe mutate", async () => {
|
||||||
|
const mockOnUpdated = mock(() => null);
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: 1 }
|
||||||
|
},
|
||||||
|
onUpdated: async () => {
|
||||||
|
mockOnUpdated();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.configs()["failing"].value).toBe(1);
|
||||||
|
|
||||||
|
// now safe mutate
|
||||||
|
// @ts-ignore
|
||||||
|
expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow(
|
||||||
|
/value must be positive/
|
||||||
|
);
|
||||||
|
expect(mm.configs()["failing"].value).toBe(1);
|
||||||
|
expect(mockOnUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it only accepts schema mutating methods", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const f = mm.mutateConfigSafe("failing");
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.has("value")).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.bypass()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.clone()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.get()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.default()).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
|||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Guard } from "../../src/auth";
|
import { Guard } from "../../src/auth";
|
||||||
|
import { DebugLogger } from "../../src/core";
|
||||||
import { EventManager } from "../../src/core/events";
|
import { EventManager } from "../../src/core/events";
|
||||||
import { Default, stripMark } from "../../src/core/utils";
|
import { Default, stripMark } from "../../src/core/utils";
|
||||||
import { EntityManager } from "../../src/data";
|
import { EntityManager } from "../../src/data";
|
||||||
import type { Module, ModuleBuildContext } from "../../src/modules/Module";
|
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||||
@@ -16,6 +17,8 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
|||||||
em: new EntityManager([], dummyConnection),
|
em: new EntityManager([], dummyConnection),
|
||||||
emgr: new EventManager(),
|
emgr: new EventManager(),
|
||||||
guard: new Guard(),
|
guard: new Guard(),
|
||||||
|
flags: Module.ctx_flags,
|
||||||
|
logger: new DebugLogger(false),
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Draft2019 } from "json-schema-library";
|
||||||
|
import type { JSONSchema } from "json-schema-to-ts";
|
||||||
|
import * as utils from "../../src/ui/components/form/json-schema-form/utils";
|
||||||
|
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
|
||||||
|
|
||||||
|
describe("json form", () => {
|
||||||
|
test("coerse", () => {
|
||||||
|
const examples = [
|
||||||
|
["test", { type: "string" }, "test"],
|
||||||
|
["1", { type: "integer" }, 1],
|
||||||
|
["1", { type: "number" }, 1],
|
||||||
|
["true", { type: "boolean" }, true],
|
||||||
|
["false", { type: "boolean" }, false],
|
||||||
|
["1", { type: "boolean" }, true],
|
||||||
|
["0", { type: "boolean" }, false],
|
||||||
|
["on", { type: "boolean" }, true],
|
||||||
|
["off", { type: "boolean" }, false],
|
||||||
|
["null", { type: "null" }, null]
|
||||||
|
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
|
||||||
|
|
||||||
|
for (const [input, schema, output] of examples) {
|
||||||
|
expect(utils.coerce(input, schema)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isType", () => {
|
||||||
|
const examples = [
|
||||||
|
["string", "string", true],
|
||||||
|
["integer", "number", false],
|
||||||
|
["number", "number", true],
|
||||||
|
["boolean", "boolean", true],
|
||||||
|
["null", "null", true],
|
||||||
|
["object", "object", true],
|
||||||
|
["array", "array", true],
|
||||||
|
["object", "array", false],
|
||||||
|
[["string", "number"], "number", true],
|
||||||
|
["number", ["string", "number"], true]
|
||||||
|
] satisfies [IsTypeType, IsTypeType, boolean][];
|
||||||
|
|
||||||
|
for (const [type, schemaType, output] of examples) {
|
||||||
|
expect(utils.isType(type, schemaType)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getParentPointer", () => {
|
||||||
|
const examples = [
|
||||||
|
["#/nested/property/0/name", "#/nested/property/0"],
|
||||||
|
["#/nested/property/0", "#/nested/property"],
|
||||||
|
["#/nested/property", "#/nested"],
|
||||||
|
["#/nested", "#"]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [input, output] of examples) {
|
||||||
|
expect(utils.getParentPointer(input)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isRequired", () => {
|
||||||
|
const examples = [
|
||||||
|
[
|
||||||
|
"#/description",
|
||||||
|
{ type: "object", properties: { description: { type: "string" } } },
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/description",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
required: ["description"],
|
||||||
|
properties: { description: { type: "string" } }
|
||||||
|
},
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/nested/property",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: {
|
||||||
|
type: "object",
|
||||||
|
properties: { property: { type: "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#/nested/property",
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nested: {
|
||||||
|
type: "object",
|
||||||
|
required: ["property"],
|
||||||
|
properties: { property: { type: "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
]
|
||||||
|
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||||
|
|
||||||
|
for (const [pointer, schema, output] of examples) {
|
||||||
|
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefixPath", () => {
|
||||||
|
const examples = [
|
||||||
|
["normal", "", "normal"],
|
||||||
|
["", "prefix", "prefix"],
|
||||||
|
["tags", "0", "0.tags"],
|
||||||
|
["tags", 0, "0.tags"],
|
||||||
|
["nested.property", "prefix", "prefix.nested.property"],
|
||||||
|
["nested.property", "", "nested.property"]
|
||||||
|
] satisfies [string, any, string][];
|
||||||
|
|
||||||
|
for (const [path, prefix, output] of examples) {
|
||||||
|
expect(utils.prefixPath(path, prefix)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suffixPath", () => {
|
||||||
|
const examples = [
|
||||||
|
["normal", "", "normal"],
|
||||||
|
["", "suffix", "suffix"],
|
||||||
|
["tags", "0", "tags.0"],
|
||||||
|
["tags", 0, "tags.0"],
|
||||||
|
["nested.property", "suffix", "nested.property.suffix"],
|
||||||
|
["nested.property", "", "nested.property"]
|
||||||
|
] satisfies [string, any, string][];
|
||||||
|
|
||||||
|
for (const [path, suffix, output] of examples) {
|
||||||
|
expect(utils.suffixPath(path, suffix)).toBe(output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+176
-134
@@ -1,186 +1,228 @@
|
|||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
import postcss from "esbuild-postcss";
|
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import { guessMimeType } from "./src/media/storage/mime-types";
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
const minify = args.includes("--minify");
|
const minify = args.includes("--minify");
|
||||||
const types = args.includes("--types");
|
const types = args.includes("--types");
|
||||||
const sourcemap = args.includes("--sourcemap");
|
const sourcemap = args.includes("--sourcemap");
|
||||||
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
|
if (clean) {
|
||||||
|
console.log("Cleaning dist (w/o static)");
|
||||||
|
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let types_running = false;
|
||||||
|
function buildTypes() {
|
||||||
|
if (types_running || !types) return;
|
||||||
|
types_running = true;
|
||||||
|
|
||||||
await $`rm -rf dist`;
|
|
||||||
if (types) {
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types built");
|
console.log("Types built");
|
||||||
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
|
stdout: "inherit",
|
||||||
|
onExit: () => {
|
||||||
|
console.log("Types aliased");
|
||||||
|
types_running = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
let watcher_timeout: any;
|
||||||
* Build static assets
|
function delayTypes() {
|
||||||
* Using esbuild because tsup doesn't include "react"
|
if (!watch || !types) return;
|
||||||
*/
|
if (watcher_timeout) {
|
||||||
const result = await esbuild.build({
|
clearTimeout(watcher_timeout);
|
||||||
minify,
|
|
||||||
sourcemap,
|
|
||||||
entryPoints: ["src/ui/main.tsx"],
|
|
||||||
entryNames: "[dir]/[name]-[hash]",
|
|
||||||
outdir: "dist/static",
|
|
||||||
platform: "browser",
|
|
||||||
bundle: true,
|
|
||||||
splitting: true,
|
|
||||||
metafile: true,
|
|
||||||
drop: ["console", "debugger"],
|
|
||||||
inject: ["src/ui/inject.js"],
|
|
||||||
target: "es2022",
|
|
||||||
format: "esm",
|
|
||||||
plugins: [postcss()],
|
|
||||||
loader: {
|
|
||||||
".svg": "dataurl",
|
|
||||||
".js": "jsx"
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__isDev: "0",
|
|
||||||
"process.env.NODE_ENV": '"production"'
|
|
||||||
},
|
|
||||||
chunkNames: "chunks/[name]-[hash]"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write manifest
|
|
||||||
{
|
|
||||||
const manifest: Record<string, object> = {};
|
|
||||||
const toAsset = (output: string) => {
|
|
||||||
const name = output.split("/").pop()!;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: output,
|
|
||||||
mime: guessMimeType(name)
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const info = Object.entries(result.metafile.outputs)
|
|
||||||
.filter(([, meta]) => {
|
|
||||||
return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx";
|
|
||||||
})
|
|
||||||
.map(([output, meta]) => ({ output, meta }));
|
|
||||||
|
|
||||||
for (const { output, meta } of info) {
|
|
||||||
manifest[meta.entryPoint as string] = toAsset(output);
|
|
||||||
if (meta.cssBundle) {
|
|
||||||
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const manifest_file = "dist/static/manifest.json";
|
if (types && !watch) {
|
||||||
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
|
buildTypes();
|
||||||
console.log(`Manifest written to ${manifest_file}`, manifest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
await tsup.build({
|
async function buildApi() {
|
||||||
minify,
|
await tsup.build({
|
||||||
sourcemap,
|
minify,
|
||||||
watch,
|
sourcemap,
|
||||||
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
watch,
|
||||||
outDir: "dist",
|
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
||||||
external: ["bun:test"],
|
outDir: "dist",
|
||||||
metafile: true,
|
external: ["bun:test", "@libsql/client", "bknd/client"],
|
||||||
platform: "browser",
|
metafile: true,
|
||||||
format: ["esm", "cjs"],
|
platform: "browser",
|
||||||
splitting: false,
|
format: ["esm"],
|
||||||
loader: {
|
splitting: false,
|
||||||
".svg": "dataurl"
|
treeshake: true,
|
||||||
}
|
loader: {
|
||||||
});
|
".svg": "dataurl"
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
delayTypes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
await tsup.build({
|
async function buildUi() {
|
||||||
minify,
|
await tsup.build({
|
||||||
sourcemap,
|
minify,
|
||||||
watch,
|
sourcemap,
|
||||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
watch,
|
||||||
outDir: "dist/ui",
|
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||||
external: ["bun:test"],
|
outDir: "dist/ui",
|
||||||
metafile: true,
|
external: [
|
||||||
platform: "browser",
|
"bun:test",
|
||||||
format: ["esm", "cjs"],
|
"react",
|
||||||
splitting: true,
|
"react-dom",
|
||||||
loader: {
|
"react/jsx-runtime",
|
||||||
".svg": "dataurl"
|
"react/jsx-dev-runtime",
|
||||||
},
|
"use-sync-external-store",
|
||||||
onSuccess: async () => {
|
/codemirror/,
|
||||||
console.log("--- ui built");
|
"@xyflow/react",
|
||||||
},
|
"@mantine/core"
|
||||||
esbuildOptions: (options) => {
|
],
|
||||||
options.chunkNames = "chunks/[name]-[hash]";
|
metafile: true,
|
||||||
}
|
platform: "browser",
|
||||||
});
|
format: ["esm"],
|
||||||
|
splitting: false,
|
||||||
|
bundle: true,
|
||||||
|
treeshake: true,
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl"
|
||||||
|
},
|
||||||
|
esbuildOptions: (options) => {
|
||||||
|
options.logLevel = "silent";
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
delayTypes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Building UI Elements
|
||||||
|
* - tailwind-merge is mocked, no exclude
|
||||||
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
|
*/
|
||||||
|
async function buildUiElements() {
|
||||||
|
await tsup.build({
|
||||||
|
minify,
|
||||||
|
sourcemap,
|
||||||
|
watch,
|
||||||
|
entry: ["src/ui/elements/index.ts"],
|
||||||
|
outDir: "dist/ui/elements",
|
||||||
|
external: [
|
||||||
|
"ui/client",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"use-sync-external-store"
|
||||||
|
],
|
||||||
|
metafile: true,
|
||||||
|
platform: "browser",
|
||||||
|
format: ["esm"],
|
||||||
|
splitting: false,
|
||||||
|
bundle: true,
|
||||||
|
treeshake: true,
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl"
|
||||||
|
},
|
||||||
|
esbuildOptions: (options) => {
|
||||||
|
options.alias = {
|
||||||
|
// not important for elements, mock to reduce bundle
|
||||||
|
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
// manually replace ui/client with bknd/client
|
||||||
|
const path = "./dist/ui/elements/index.js";
|
||||||
|
const bundle = await Bun.file(path).text();
|
||||||
|
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
|
||||||
|
|
||||||
|
delayTypes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string): tsup.Options {
|
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||||
return {
|
return {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: [`src/adapter/${adapter}`],
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
outDir: `dist/adapter/${adapter}`,
|
outDir: `dist/adapter/${adapter}`,
|
||||||
|
metafile: true,
|
||||||
|
splitting: false,
|
||||||
|
onSuccess: async () => {
|
||||||
|
delayTypes();
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0"
|
__isDev: "0",
|
||||||
|
...overrides.define
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
/^cloudflare*/,
|
/^cloudflare*/,
|
||||||
/^@?(hono|libsql).*?/,
|
/^@?(hono|libsql).*?/,
|
||||||
/^(bknd|react|next|node).*?/,
|
/^(bknd|react|next|node).*?/,
|
||||||
/.*\.(html)$/
|
/.*\.(html)$/,
|
||||||
],
|
...(Array.isArray(overrides.external) ? overrides.external : [])
|
||||||
metafile: true,
|
]
|
||||||
splitting: false,
|
|
||||||
treeshake: true
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await tsup.build({
|
async function buildAdapters() {
|
||||||
...baseConfig("vite"),
|
// base adapter handles
|
||||||
platform: "node"
|
await tsup.build({
|
||||||
});
|
...baseConfig(""),
|
||||||
|
entry: ["src/adapter/index.ts"],
|
||||||
|
outDir: "dist/adapter"
|
||||||
|
});
|
||||||
|
|
||||||
await tsup.build({
|
// specific adatpers
|
||||||
...baseConfig("cloudflare")
|
await tsup.build(baseConfig("remix"));
|
||||||
});
|
await tsup.build(baseConfig("bun"));
|
||||||
|
await tsup.build(baseConfig("astro"));
|
||||||
|
await tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: [/^kysely/]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("nextjs"),
|
...baseConfig("vite"),
|
||||||
format: ["esm", "cjs"],
|
platform: "node"
|
||||||
platform: "node"
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("remix"),
|
...baseConfig("nextjs"),
|
||||||
format: ["esm", "cjs"]
|
platform: "node"
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("bun")
|
...baseConfig("node"),
|
||||||
});
|
platform: "node"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tsup.build({
|
await buildApi();
|
||||||
...baseConfig("node"),
|
await buildUi();
|
||||||
platform: "node",
|
await buildUiElements();
|
||||||
format: ["esm", "cjs"]
|
await buildAdapters();
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
|
||||||
...baseConfig("astro"),
|
|
||||||
format: ["esm", "cjs"]
|
|
||||||
});
|
|
||||||
|
|||||||
+4
-1
@@ -1,2 +1,5 @@
|
|||||||
[install]
|
[install]
|
||||||
registry = "http://localhost:4873"
|
#registry = "http://localhost:4873"
|
||||||
|
|
||||||
|
[test]
|
||||||
|
coverageSkipTestFiles = true
|
||||||
+100
-52
@@ -3,21 +3,32 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.2.2",
|
"version": "0.8.0",
|
||||||
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
|
"homepage": "https://bknd.io",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bknd-io/bknd.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:all": "bun run build && bun run build:cli",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"build": "bun run build.ts --minify --types",
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
|
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||||
|
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||||
|
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
||||||
|
"build:static": "vite build",
|
||||||
"watch": "bun run build.ts --types --watch",
|
"watch": "bun run build.ts --types --watch",
|
||||||
"types": "bun tsc --noEmit",
|
"types": "bun tsc --noEmit",
|
||||||
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
||||||
"build:types": "tsc --emitDeclarationOnly",
|
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||||
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
|
|
||||||
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
|
|
||||||
"updater": "bun x npm-check-updates -ui",
|
"updater": "bun x npm-check-updates -ui",
|
||||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||||
"cli": "LOCAL=1 bun src/cli/index.ts"
|
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
|
||||||
|
"postpublish": "rm -f README.md"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,64 +36,71 @@
|
|||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-liquid": "^6.2.1",
|
"@codemirror/lang-liquid": "^6.2.1",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@hono/typebox-validator": "^0.2.6",
|
|
||||||
"@hono/zod-validator": "^0.4.1",
|
|
||||||
"@hookform/resolvers": "^3.9.1",
|
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.4",
|
||||||
"@mantine/hooks": "^7.13.4",
|
|
||||||
"@mantine/modals": "^7.13.4",
|
|
||||||
"@mantine/notifications": "^7.13.5",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
|
||||||
"@rjsf/core": "^5.22.2",
|
|
||||||
"@sinclair/typebox": "^0.32.34",
|
"@sinclair/typebox": "^0.32.34",
|
||||||
"@tabler/icons-react": "3.18.0",
|
|
||||||
"@tanstack/react-form": "0.19.2",
|
"@tanstack/react-form": "0.19.2",
|
||||||
"@tanstack/react-query": "^5.59.16",
|
|
||||||
"@uiw/react-codemirror": "^4.23.6",
|
"@uiw/react-codemirror": "^4.23.6",
|
||||||
"@xyflow/react": "^12.3.2",
|
"@xyflow/react": "^12.3.2",
|
||||||
"aws4fetch": "^1.0.18",
|
"aws4fetch": "^1.0.18",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^4.4.0",
|
"fast-xml-parser": "^4.4.0",
|
||||||
"hono": "^4.6.12",
|
"hono": "^4.6.12",
|
||||||
"jotai": "^2.10.1",
|
"json-schema-form-react": "^0.0.2",
|
||||||
|
"json-schema-library": "^10.0.0-rc7",
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"react-hook-form": "^7.53.1",
|
"object-path-immutable": "^4.1.2",
|
||||||
"react-icons": "5.2.1",
|
"picocolors": "^1.1.1",
|
||||||
"react-json-view-lite": "^2.0.1",
|
"radix-ui": "^1.1.2",
|
||||||
"tailwind-merge": "^2.5.4",
|
"swr": "^2.2.5"
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"wouter": "^3.3.5",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
"@hono/node-server": "^1.13.7",
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
|
"@hono/typebox-validator": "^0.2.6",
|
||||||
"@hono/vite-dev-server": "^0.17.0",
|
"@hono/vite-dev-server": "^0.17.0",
|
||||||
"@tanstack/react-query-devtools": "^5.59.16",
|
"@hono/zod-validator": "^0.4.1",
|
||||||
"@types/diff": "^5.2.3",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
|
"@rjsf/core": "^5.22.2",
|
||||||
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"esbuild-postcss": "^0.0.4",
|
"esbuild-postcss": "^0.0.4",
|
||||||
"node-fetch": "^3.3.2",
|
"jotai": "^2.10.1",
|
||||||
|
"kysely-d1": "^0.3.0",
|
||||||
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"react-hook-form": "^7.53.1",
|
||||||
|
"react-icons": "5.2.1",
|
||||||
|
"react-json-view-lite": "^2.0.1",
|
||||||
|
"sql-formatter": "^15.4.9",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsc-alias": "^1.8.10",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-static-copy": "^2.0.0",
|
"vite-plugin-static-copy": "^2.0.0",
|
||||||
"vite-tsconfig-paths": "^5.0.1"
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
|
"wouter": "^3.3.5"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@hono/node-server": "^1.13.7"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
@@ -90,80 +108,93 @@
|
|||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
},
|
},
|
||||||
"./ui": {
|
"./ui": {
|
||||||
"types": "./dist/ui/index.d.ts",
|
"types": "./dist/types/ui/index.d.ts",
|
||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./elements": {
|
||||||
|
"types": "./dist/types/ui/elements/index.d.ts",
|
||||||
|
"import": "./dist/ui/elements/index.js",
|
||||||
|
"require": "./dist/ui/elements/index.cjs"
|
||||||
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
"types": "./dist/ui/client/index.d.ts",
|
"types": "./dist/types/ui/client/index.d.ts",
|
||||||
"import": "./dist/ui/client/index.js",
|
"import": "./dist/ui/client/index.js",
|
||||||
"require": "./dist/ui/client/index.cjs"
|
"require": "./dist/ui/client/index.cjs"
|
||||||
},
|
},
|
||||||
"./data": {
|
"./data": {
|
||||||
"types": "./dist/data/index.d.ts",
|
"types": "./dist/types/data/index.d.ts",
|
||||||
"import": "./dist/data/index.js",
|
"import": "./dist/data/index.js",
|
||||||
"require": "./dist/data/index.cjs"
|
"require": "./dist/data/index.cjs"
|
||||||
},
|
},
|
||||||
"./core": {
|
"./core": {
|
||||||
"types": "./dist/core/index.d.ts",
|
"types": "./dist/types/core/index.d.ts",
|
||||||
"import": "./dist/core/index.js",
|
"import": "./dist/core/index.js",
|
||||||
"require": "./dist/core/index.cjs"
|
"require": "./dist/core/index.cjs"
|
||||||
},
|
},
|
||||||
"./utils": {
|
"./utils": {
|
||||||
"types": "./dist/core/utils/index.d.ts",
|
"types": "./dist/types/core/utils/index.d.ts",
|
||||||
"import": "./dist/core/utils/index.js",
|
"import": "./dist/core/utils/index.js",
|
||||||
"require": "./dist/core/utils/index.cjs"
|
"require": "./dist/core/utils/index.cjs"
|
||||||
},
|
},
|
||||||
"./cli": {
|
"./cli": {
|
||||||
"types": "./dist/cli/index.d.ts",
|
"types": "./dist/types/cli/index.d.ts",
|
||||||
"import": "./dist/cli/index.js",
|
"import": "./dist/cli/index.js",
|
||||||
"require": "./dist/cli/index.cjs"
|
"require": "./dist/cli/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/cloudflare": {
|
"./adapter/cloudflare": {
|
||||||
"types": "./dist/adapter/cloudflare/index.d.ts",
|
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
||||||
"import": "./dist/adapter/cloudflare/index.js",
|
"import": "./dist/adapter/cloudflare/index.js",
|
||||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./adapter": {
|
||||||
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
|
"import": "./dist/adapter/index.js"
|
||||||
|
},
|
||||||
"./adapter/vite": {
|
"./adapter/vite": {
|
||||||
"types": "./dist/adapter/vite/index.d.ts",
|
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||||
"import": "./dist/adapter/vite/index.js",
|
"import": "./dist/adapter/vite/index.js",
|
||||||
"require": "./dist/adapter/vite/index.cjs"
|
"require": "./dist/adapter/vite/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/nextjs": {
|
"./adapter/nextjs": {
|
||||||
"types": "./dist/adapter/nextjs/index.d.ts",
|
"types": "./dist/types/adapter/nextjs/index.d.ts",
|
||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.cjs"
|
"require": "./dist/adapter/nextjs/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/remix": {
|
"./adapter/remix": {
|
||||||
"types": "./dist/adapter/remix/index.d.ts",
|
"types": "./dist/types/adapter/remix/index.d.ts",
|
||||||
"import": "./dist/adapter/remix/index.js",
|
"import": "./dist/adapter/remix/index.js",
|
||||||
"require": "./dist/adapter/remix/index.cjs"
|
"require": "./dist/adapter/remix/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/bun": {
|
"./adapter/bun": {
|
||||||
"types": "./dist/adapter/bun/index.d.ts",
|
"types": "./dist/types/adapter/bun/index.d.ts",
|
||||||
"import": "./dist/adapter/bun/index.js",
|
"import": "./dist/adapter/bun/index.js",
|
||||||
"require": "./dist/adapter/bun/index.cjs"
|
"require": "./dist/adapter/bun/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/node": {
|
"./adapter/node": {
|
||||||
"types": "./dist/adapter/node/index.d.ts",
|
"types": "./dist/types/adapter/node/index.d.ts",
|
||||||
"import": "./dist/adapter/node/index.js",
|
"import": "./dist/adapter/node/index.js",
|
||||||
"require": "./dist/adapter/node/index.cjs"
|
"require": "./dist/adapter/node/index.cjs"
|
||||||
},
|
},
|
||||||
"./adapter/astro": {
|
"./adapter/astro": {
|
||||||
"types": "./dist/adapter/astro/index.d.ts",
|
"types": "./dist/types/adapter/astro/index.d.ts",
|
||||||
"import": "./dist/adapter/astro/index.js",
|
"import": "./dist/adapter/astro/index.js",
|
||||||
"require": "./dist/adapter/astro/index.cjs"
|
"require": "./dist/adapter/astro/index.cjs"
|
||||||
},
|
},
|
||||||
"./dist/styles.css": "./dist/ui/main.css",
|
"./dist/main.css": "./dist/ui/main.css",
|
||||||
"./dist/manifest.json": "./dist/static/manifest.json"
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -171,6 +202,23 @@
|
|||||||
"!dist/*.tsbuildinfo",
|
"!dist/*.tsbuildinfo",
|
||||||
"!dist/*.map",
|
"!dist/*.map",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
"!dist/metafile*"
|
"!dist/metafile*",
|
||||||
|
"!dist/**/metafile*"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"backend",
|
||||||
|
"database",
|
||||||
|
"authentication",
|
||||||
|
"media",
|
||||||
|
"workflows",
|
||||||
|
"api",
|
||||||
|
"jwt",
|
||||||
|
"serverless",
|
||||||
|
"cloudflare",
|
||||||
|
"nextjs",
|
||||||
|
"remix",
|
||||||
|
"astro",
|
||||||
|
"bun",
|
||||||
|
"node"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+111
-27
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SafeUser } from "auth";
|
||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
@@ -5,7 +6,7 @@ import { omit } from "lodash-es";
|
|||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
|
|
||||||
export type TApiUser = object;
|
export type TApiUser = SafeUser;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -16,12 +17,27 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host: string;
|
host?: string;
|
||||||
user?: TApiUser;
|
|
||||||
token?: string;
|
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
key?: string;
|
key?: string;
|
||||||
localStorage?: boolean;
|
localStorage?: boolean;
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
verbose?: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
token?: string;
|
||||||
|
user?: TApiUser;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
request: Request;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AuthState = {
|
||||||
|
token?: string;
|
||||||
|
user?: TApiUser;
|
||||||
|
verified: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Api {
|
export class Api {
|
||||||
@@ -35,14 +51,26 @@ export class Api {
|
|||||||
public auth!: AuthApi;
|
public auth!: AuthApi;
|
||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private readonly options: ApiOptions) {
|
constructor(private options: ApiOptions = {}) {
|
||||||
if (options.user) {
|
// only mark verified if forced
|
||||||
this.user = options.user;
|
this.verified = options.verified === true;
|
||||||
this.token_transport = "none";
|
|
||||||
this.verified = true;
|
// prefer request if given
|
||||||
} else if (options.token) {
|
if ("request" in options) {
|
||||||
|
this.options.host = options.host ?? new URL(options.request.url).origin;
|
||||||
|
this.options.headers = options.headers ?? options.request.headers;
|
||||||
|
this.extractToken();
|
||||||
|
|
||||||
|
// then check for a token
|
||||||
|
} else if ("token" in options) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token);
|
||||||
|
|
||||||
|
// then check for an user object
|
||||||
|
} else if ("user" in options) {
|
||||||
|
this.token_transport = "none";
|
||||||
|
this.user = options.user;
|
||||||
|
this.verified = options.verified !== false;
|
||||||
} else {
|
} else {
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
}
|
}
|
||||||
@@ -50,18 +78,24 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get baseUrl() {
|
||||||
|
return this.options.host ?? "http://localhost";
|
||||||
|
}
|
||||||
|
|
||||||
get tokenKey() {
|
get tokenKey() {
|
||||||
return this.options.key ?? "auth";
|
return this.options.key ?? "auth";
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractToken() {
|
private extractToken() {
|
||||||
|
// if token has to be extracted, it's never verified
|
||||||
|
this.verified = false;
|
||||||
|
|
||||||
if (this.options.headers) {
|
if (this.options.headers) {
|
||||||
// try cookies
|
// try cookies
|
||||||
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||||
if (cookieToken) {
|
if (cookieToken) {
|
||||||
this.updateToken(cookieToken);
|
|
||||||
this.token_transport = "cookie";
|
this.token_transport = "cookie";
|
||||||
this.verified = true;
|
this.updateToken(cookieToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +119,13 @@ export class Api {
|
|||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
|
this.verified = false;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||||
|
} else {
|
||||||
|
this.user = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.options.localStorage) {
|
if (this.options.localStorage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
@@ -100,12 +140,16 @@ export class Api {
|
|||||||
if (rebuild) this.buildApis();
|
if (rebuild) this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
this.verified = verfied;
|
this.verified = verfied;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthState() {
|
isAuthVerified(): boolean {
|
||||||
|
return this.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthState(): AuthState {
|
||||||
return {
|
return {
|
||||||
token: this.token,
|
token: this.token,
|
||||||
user: this.user,
|
user: this.user,
|
||||||
@@ -113,25 +157,65 @@ export class Api {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
const { token, user } = this.getAuthState();
|
||||||
|
return !!token && !!user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerifiedAuthState(): Promise<AuthState> {
|
||||||
|
await this.verifyAuth();
|
||||||
|
return this.getAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAuth() {
|
||||||
|
if (!this.token) {
|
||||||
|
this.markAuthVerified(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { ok, data } = await this.auth.me();
|
||||||
|
const user = data?.user;
|
||||||
|
if (!ok || !user) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.user = user;
|
||||||
|
this.markAuthVerified(true);
|
||||||
|
} catch (e) {
|
||||||
|
this.markAuthVerified(false);
|
||||||
|
this.updateToken(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getUser(): TApiUser | null {
|
getUser(): TApiUser | null {
|
||||||
return this.user || null;
|
return this.user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildApis() {
|
getParams() {
|
||||||
const baseParams = {
|
return Object.freeze({
|
||||||
host: this.options.host,
|
host: this.baseUrl,
|
||||||
token: this.token,
|
token: this.token,
|
||||||
headers: this.options.headers,
|
headers: this.options.headers,
|
||||||
token_transport: this.token_transport
|
token_transport: this.token_transport,
|
||||||
};
|
verbose: this.options.verbose
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams);
|
|
||||||
this.data = new DataApi(baseParams);
|
|
||||||
this.auth = new AuthApi({
|
|
||||||
...baseParams,
|
|
||||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
|
||||||
});
|
});
|
||||||
this.media = new MediaApi(baseParams);
|
}
|
||||||
|
|
||||||
|
private buildApis() {
|
||||||
|
const baseParams = this.getParams();
|
||||||
|
const fetcher = this.options.fetcher;
|
||||||
|
|
||||||
|
this.system = new SystemApi(baseParams, fetcher);
|
||||||
|
this.data = new DataApi(baseParams, fetcher);
|
||||||
|
this.auth = new AuthApi(
|
||||||
|
{
|
||||||
|
...baseParams,
|
||||||
|
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||||
|
},
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
this.media = new MediaApi(baseParams, fetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+126
-55
@@ -1,7 +1,12 @@
|
|||||||
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
|
import { Api, type ApiOptions } from "bknd/client";
|
||||||
|
import { $console } from "core";
|
||||||
import { Event } from "core/events";
|
import { Event } from "core/events";
|
||||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
|
type ModuleBuildContext,
|
||||||
ModuleManager,
|
ModuleManager,
|
||||||
type ModuleManagerOptions,
|
type ModuleManagerOptions,
|
||||||
type Modules
|
type Modules
|
||||||
@@ -10,113 +15,127 @@ import * as SystemPermissions from "modules/permissions";
|
|||||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
|
||||||
export type AppPlugin<DB> = (app: App<DB>) => void;
|
export type AppPlugin = (app: App) => Promise<void> | void;
|
||||||
|
|
||||||
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
|
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
||||||
|
export class AppConfigUpdatedEvent extends AppEvent {
|
||||||
static override slug = "app-config-updated";
|
static override slug = "app-config-updated";
|
||||||
}
|
}
|
||||||
export class AppBuiltEvent extends Event<{ app: App }> {
|
export class AppBuiltEvent extends AppEvent {
|
||||||
static override slug = "app-built";
|
static override slug = "app-built";
|
||||||
}
|
}
|
||||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
|
export class AppFirstBoot extends AppEvent {
|
||||||
|
static override slug = "app-first-boot";
|
||||||
|
}
|
||||||
|
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
||||||
|
|
||||||
|
export type AppOptions = {
|
||||||
|
plugins?: AppPlugin[];
|
||||||
|
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||||
|
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
||||||
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
connection:
|
connection?:
|
||||||
| Connection
|
| Connection
|
||||||
| {
|
| {
|
||||||
|
// @deprecated
|
||||||
type: "libsql";
|
type: "libsql";
|
||||||
config: LibSqlCredentials;
|
config: LibSqlCredentials;
|
||||||
};
|
}
|
||||||
|
| LibSqlCredentials;
|
||||||
initialConfig?: InitialModuleConfigs;
|
initialConfig?: InitialModuleConfigs;
|
||||||
plugins?: AppPlugin<any>[];
|
options?: AppOptions;
|
||||||
options?: ModuleManagerOptions;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = InitialModuleConfigs;
|
export type AppConfig = InitialModuleConfigs;
|
||||||
|
|
||||||
export class App<DB = any> {
|
export class App {
|
||||||
modules: ModuleManager;
|
modules: ModuleManager;
|
||||||
static readonly Events = AppEvents;
|
static readonly Events = AppEvents;
|
||||||
|
adminController?: AdminController;
|
||||||
|
private trigger_first_boot = false;
|
||||||
|
private plugins: AppPlugin[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private connection: Connection,
|
private connection: Connection,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_initialConfig?: InitialModuleConfigs,
|
||||||
private plugins: AppPlugin<DB>[] = [],
|
private options?: AppOptions
|
||||||
moduleManagerOptions?: ModuleManagerOptions
|
|
||||||
) {
|
) {
|
||||||
|
this.plugins = options?.plugins ?? [];
|
||||||
this.modules = new ModuleManager(connection, {
|
this.modules = new ModuleManager(connection, {
|
||||||
...moduleManagerOptions,
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _initialConfig,
|
||||||
|
seed: options?.seed,
|
||||||
onUpdated: async (key, config) => {
|
onUpdated: async (key, config) => {
|
||||||
//console.log("[APP] config updated", key, config);
|
// if the EventManager was disabled, we assume we shouldn't
|
||||||
await this.build({ sync: true, save: true });
|
// respond to events, such as "onUpdated".
|
||||||
|
// this is important if multiple changes are done, and then build() is called manually
|
||||||
|
if (!this.emgr.enabled) {
|
||||||
|
$console.warn("App config updated, but event manager is disabled, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$console.log("App config updated", key);
|
||||||
|
// @todo: potentially double syncing
|
||||||
|
await this.build({ sync: true });
|
||||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||||
|
},
|
||||||
|
onFirstBoot: async () => {
|
||||||
|
$console.log("App first boot");
|
||||||
|
this.trigger_first_boot = true;
|
||||||
|
},
|
||||||
|
onServerInit: async (server) => {
|
||||||
|
server.use(async (c, next) => {
|
||||||
|
c.set("app", this);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(config: CreateAppConfig) {
|
|
||||||
let connection: Connection | undefined = undefined;
|
|
||||||
|
|
||||||
if (Connection.isConnection(config.connection)) {
|
|
||||||
connection = config.connection;
|
|
||||||
} else if (typeof config.connection === "object") {
|
|
||||||
switch (config.connection.type) {
|
|
||||||
case "libsql":
|
|
||||||
connection = new LibsqlConnection(config.connection.config);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
|
|
||||||
}
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error("Invalid connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new App(connection, config.initialConfig, config.plugins, config.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
get emgr() {
|
get emgr() {
|
||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
|
async build(options?: { sync?: boolean }) {
|
||||||
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
await this.modules.build();
|
await this.modules.build();
|
||||||
|
|
||||||
if (options?.sync) {
|
const { guard, server } = this.modules.ctx();
|
||||||
const syncResult = await this.module.data.em
|
|
||||||
.schema()
|
|
||||||
.sync({ force: true, drop: options.drop });
|
|
||||||
//console.log("syncing", syncResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
// load system controller
|
// load system controller
|
||||||
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
|
guard.registerPermissions(Object.values(SystemPermissions));
|
||||||
this.modules.server.route("/api/system", new SystemController(this).getController());
|
server.route("/api/system", new SystemController(this).getController());
|
||||||
|
|
||||||
// load plugins
|
// load plugins
|
||||||
if (this.plugins.length > 0) {
|
if (this.plugins.length > 0) {
|
||||||
this.plugins.forEach((plugin) => plugin(this));
|
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("emitting built", options);
|
|
||||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||||
|
|
||||||
// not found on any not registered api route
|
// first boot is set from ModuleManager when there wasn't a config table
|
||||||
this.modules.server.all("/api/*", async (c) => c.notFound());
|
if (this.trigger_first_boot) {
|
||||||
|
this.trigger_first_boot = false;
|
||||||
if (options?.save) {
|
await this.emgr.emit(new AppFirstBoot({ app: this }));
|
||||||
await this.modules.save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||||
return this.modules.get(module).schema();
|
return this.modules.mutateConfigSafe(module);
|
||||||
}
|
}
|
||||||
|
|
||||||
get fetch(): any {
|
get server() {
|
||||||
return this.modules.server.fetch;
|
return this.modules.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.modules.ctx().em;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fetch(): Hono["fetch"] {
|
||||||
|
return this.server.fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
get module() {
|
get module() {
|
||||||
@@ -138,13 +157,65 @@ export class App<DB = any> {
|
|||||||
return this.modules.version();
|
return this.modules.version();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isBuilt(): boolean {
|
||||||
|
return this.modules.isBuilt();
|
||||||
|
}
|
||||||
|
|
||||||
registerAdminController(config?: AdminControllerOptions) {
|
registerAdminController(config?: AdminControllerOptions) {
|
||||||
// register admin
|
// register admin
|
||||||
this.modules.server.route("/", new AdminController(this, config).getController());
|
this.adminController = new AdminController(this, config);
|
||||||
|
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return this.modules.toJSON(secrets);
|
return this.modules.toJSON(secrets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static create(config: CreateAppConfig) {
|
||||||
|
return createApp(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(p: CreateUserPayload) {
|
||||||
|
return this.module.auth.createUser(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApi(options: Request | ApiOptions = {}) {
|
||||||
|
const fetcher = this.server.request as typeof fetch;
|
||||||
|
if (options instanceof Request) {
|
||||||
|
return new Api({ request: options, headers: options.headers, fetcher });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Api({ host: "http://localhost", ...options, fetcher });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(config: CreateAppConfig = {}) {
|
||||||
|
let connection: Connection | undefined = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Connection.isConnection(config.connection)) {
|
||||||
|
connection = config.connection;
|
||||||
|
} else if (typeof config.connection === "object") {
|
||||||
|
if ("type" in config.connection) {
|
||||||
|
$console.warn(
|
||||||
|
"Using deprecated connection type 'libsql', use the 'config' object directly."
|
||||||
|
);
|
||||||
|
connection = new LibsqlConnection(config.connection.config);
|
||||||
|
} else {
|
||||||
|
connection = new LibsqlConnection(config.connection);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connection = new LibsqlConnection({ url: ":memory:" });
|
||||||
|
$console.warn("No connection provided, using in-memory database");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
$console.error("Could not create connection", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
throw new Error("Invalid connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new App(connection, config.initialConfig, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Api, type ApiOptions } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { App, type CreateAppConfig } from "bknd";
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
import { Api, type ApiOptions } from "bknd/client";
|
||||||
|
|
||||||
|
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
|
||||||
|
|
||||||
type TAstro = {
|
type TAstro = {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -11,20 +14,20 @@ export type Options = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||||
return new Api({
|
const api = new Api({
|
||||||
host: new URL(Astro.request.url).origin,
|
host: new URL(Astro.request.url).origin,
|
||||||
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
||||||
});
|
});
|
||||||
|
await api.verifyAuth();
|
||||||
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: CreateAppConfig) {
|
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
|
||||||
return async (args: TAstro) => {
|
return async (args: Context) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = App.create(config);
|
app = await createFrameworkApp(config, args);
|
||||||
|
|
||||||
await app.build();
|
|
||||||
}
|
}
|
||||||
return app.fetch(args.request);
|
return app.fetch(args.request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,56 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { App, type CreateAppConfig } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { LibsqlConnection } from "bknd/data";
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
|
import { config } from "bknd/core";
|
||||||
|
import type { ServeOptions } from "bun";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
|
||||||
async function getConnection(conn?: CreateAppConfig["connection"]) {
|
let app: App;
|
||||||
if (conn) {
|
|
||||||
if (LibsqlConnection.isConnection(conn)) {
|
|
||||||
return conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LibsqlConnection(conn.config);
|
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
||||||
}
|
|
||||||
|
|
||||||
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
|
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
|
||||||
if (!createClient) {
|
|
||||||
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Using in-memory database");
|
|
||||||
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
|
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
let app: App;
|
|
||||||
|
|
||||||
return async (req: Request) => {
|
if (!app) {
|
||||||
if (!app) {
|
registerLocalMediaAdapter();
|
||||||
const connection = await getConnection(_config.connection);
|
app = await createRuntimeApp({
|
||||||
app = App.create({
|
...config,
|
||||||
..._config,
|
serveStatic: serveStatic({ root })
|
||||||
connection
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
app.emgr.on(
|
return app;
|
||||||
"app-built",
|
}
|
||||||
async () => {
|
|
||||||
app.modules.server.get(
|
export function serve({
|
||||||
"/*",
|
distPath,
|
||||||
serveStatic({
|
connection,
|
||||||
root
|
initialConfig,
|
||||||
})
|
options,
|
||||||
);
|
port = config.server.default_port,
|
||||||
app.registerAdminController();
|
onBuilt,
|
||||||
},
|
buildConfig,
|
||||||
"sync"
|
...serveOptions
|
||||||
);
|
}: BunBkndConfig = {}) {
|
||||||
|
Bun.serve({
|
||||||
await app.build();
|
...serveOptions,
|
||||||
}
|
port,
|
||||||
|
fetch: async (request: Request) => {
|
||||||
return app.fetch(req);
|
const app = await createApp({
|
||||||
};
|
connection,
|
||||||
|
initialConfig,
|
||||||
|
options,
|
||||||
|
onBuilt,
|
||||||
|
buildConfig,
|
||||||
|
distPath
|
||||||
|
});
|
||||||
|
return app.fetch(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Server is running on http://localhost:${port}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
|
||||||
|
import type { QB } from "data/connection/Connection";
|
||||||
|
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||||
|
import { D1Dialect } from "kysely-d1";
|
||||||
|
|
||||||
|
export type D1ConnectionConfig = {
|
||||||
|
binding: D1Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CustomD1Dialect extends D1Dialect {
|
||||||
|
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||||
|
return new SqliteIntrospector(db, {
|
||||||
|
excludeTables: ["_cf_KV"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class D1Connection extends SqliteConnection {
|
||||||
|
constructor(private config: D1ConnectionConfig) {
|
||||||
|
const plugins = [new ParseJSONResultsPlugin()];
|
||||||
|
|
||||||
|
const kysely = new Kysely({
|
||||||
|
dialect: new CustomD1Dialect({ database: config.binding }),
|
||||||
|
plugins
|
||||||
|
});
|
||||||
|
super(kysely, {}, plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
override supportsBatching(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override supportsIndices(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async batch<Queries extends QB[]>(
|
||||||
|
queries: [...Queries]
|
||||||
|
): Promise<{
|
||||||
|
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||||
|
}> {
|
||||||
|
const db = this.config.binding;
|
||||||
|
|
||||||
|
const res = await db.batch(
|
||||||
|
queries.map((q) => {
|
||||||
|
const { sql, parameters } = q.compile();
|
||||||
|
return db.prepare(sql).bind(...parameters);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// let it run through plugins
|
||||||
|
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
|
||||||
|
const data: any = [];
|
||||||
|
for (const r of res) {
|
||||||
|
const rows = await kyselyPlugins.transformResultRows(r.results);
|
||||||
|
data.push(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-8
@@ -1,6 +1,47 @@
|
|||||||
import { isDebug } from "core";
|
import { registries } from "bknd";
|
||||||
import type { FileBody, StorageAdapter } from "../Storage";
|
import { isDebug } from "bknd/core";
|
||||||
import { guessMimeType } from "../mime-types";
|
import { StringEnum, Type } from "bknd/utils";
|
||||||
|
import type { FileBody, StorageAdapter } from "media/storage/Storage";
|
||||||
|
import { guess } from "media/storage/mime-types-tiny";
|
||||||
|
import { getBindings } from "./bindings";
|
||||||
|
|
||||||
|
export function makeSchema(bindings: string[] = []) {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String())
|
||||||
|
},
|
||||||
|
{ title: "R2", description: "Cloudflare R2 storage" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMedia(env: Record<string, any>) {
|
||||||
|
const r2_bindings = getBindings(env, "R2Bucket");
|
||||||
|
|
||||||
|
registries.media.register(
|
||||||
|
"r2",
|
||||||
|
class extends StorageR2Adapter {
|
||||||
|
constructor(private config: any) {
|
||||||
|
const binding = r2_bindings.find((b) => b.key === config.binding);
|
||||||
|
if (!binding) {
|
||||||
|
throw new Error(`No R2Bucket found with key ${config.binding}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(binding?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getSchema() {
|
||||||
|
return makeSchema(r2_bindings.map((b) => b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON() {
|
||||||
|
return {
|
||||||
|
...super.toJSON(),
|
||||||
|
config: this.config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter for R2 storage
|
* Adapter for R2 storage
|
||||||
@@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return undefined;
|
return makeSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
async putObject(key: string, body: FileBody) {
|
async putObject(key: string, body: FileBody) {
|
||||||
@@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||||
let object: R2ObjectBody | null;
|
let object: R2ObjectBody | null;
|
||||||
const responseHeaders = new Headers({
|
const responseHeaders = new Headers({
|
||||||
"Accept-Ranges": "bytes"
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": guess(key)
|
||||||
});
|
});
|
||||||
|
|
||||||
//console.log("getObject:headers", headersToObject(headers));
|
//console.log("getObject:headers", headersToObject(headers));
|
||||||
@@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
if (!metadata || Object.keys(metadata).length === 0) {
|
if (!metadata || Object.keys(metadata).length === 0) {
|
||||||
// guessing is especially required for dev environment (miniflare)
|
// guessing is especially required for dev environment (miniflare)
|
||||||
metadata = {
|
metadata = {
|
||||||
contentType: guessMimeType(object.key)
|
contentType: guess(object.key)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
//console.log("writeHttpMetadata", object.httpMetadata, metadata);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(metadata)) {
|
for (const [key, value] of Object.entries(metadata)) {
|
||||||
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||||
@@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: String(head.httpMetadata?.contentType ?? "application/octet-stream"),
|
type: String(head.httpMetadata?.contentType ?? guess(key)),
|
||||||
size: head.size
|
size: head.size
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type BindingTypeMap = {
|
||||||
|
D1Database: D1Database;
|
||||||
|
KVNamespace: KVNamespace;
|
||||||
|
DurableObjectNamespace: DurableObjectNamespace;
|
||||||
|
R2Bucket: R2Bucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetBindingType = keyof BindingTypeMap;
|
||||||
|
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||||
|
|
||||||
|
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||||
|
const bindings: BindingMap<T>[] = [];
|
||||||
|
for (const key in env) {
|
||||||
|
try {
|
||||||
|
if (env[key] && (env[key] as any).constructor.name === type) {
|
||||||
|
bindings.push({
|
||||||
|
key,
|
||||||
|
value: env[key] as BindingTypeMap[T]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBinding<T extends GetBindingType>(env: any, type: T): BindingMap<T> {
|
||||||
|
const bindings = getBindings(env, type);
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
throw new Error(`No ${type} found in bindings`);
|
||||||
|
}
|
||||||
|
return bindings[0] as BindingMap<T>;
|
||||||
|
}
|
||||||
@@ -1,38 +1,93 @@
|
|||||||
import { DurableObject } from "cloudflare:workers";
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
import { App, type CreateAppConfig } from "bknd";
|
|
||||||
|
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import type { BkndConfig, CfBkndModeCache } from "../index";
|
import { D1Connection } from "./D1Connection";
|
||||||
|
import { registerMedia } from "./StorageR2Adapter";
|
||||||
|
import { getBinding } from "./bindings";
|
||||||
|
import { getCached } from "./modes/cached";
|
||||||
|
import { getDurable } from "./modes/durable";
|
||||||
|
import { getFresh, getWarm } from "./modes/fresh";
|
||||||
|
|
||||||
type Context = {
|
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
|
||||||
request: Request;
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
env: any;
|
bindings?: (args: Context<Env>) => {
|
||||||
ctx: ExecutionContext;
|
kv?: KVNamespace;
|
||||||
manifest: any;
|
dobj?: DurableObjectNamespace;
|
||||||
|
db?: D1Database;
|
||||||
|
};
|
||||||
|
static?: "kv" | "assets";
|
||||||
|
key?: string;
|
||||||
|
keepAliveSeconds?: number;
|
||||||
|
forceHttps?: boolean;
|
||||||
|
manifest?: string;
|
||||||
|
setAdminHtml?: boolean;
|
||||||
html?: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
export type Context<Env = any> = {
|
||||||
|
request: Request;
|
||||||
|
env: Env;
|
||||||
|
ctx: ExecutionContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
let media_registered: boolean = false;
|
||||||
|
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
|
||||||
|
if (!media_registered) {
|
||||||
|
registerMedia(context.env as any);
|
||||||
|
media_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = makeConfig(config, context);
|
||||||
|
const bindings = config.bindings?.(context);
|
||||||
|
if (!appConfig.connection) {
|
||||||
|
let db: D1Database | undefined;
|
||||||
|
if (bindings?.db) {
|
||||||
|
console.log("Using database from bindings");
|
||||||
|
db = bindings.db;
|
||||||
|
} else if (Object.keys(context.env ?? {}).length > 0) {
|
||||||
|
const binding = getBinding(context.env, "D1Database");
|
||||||
|
if (binding) {
|
||||||
|
console.log(`Using database from env "${binding.key}"`);
|
||||||
|
db = binding.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
appConfig.connection = new D1Connection({ binding: db });
|
||||||
|
} else {
|
||||||
|
throw new Error("No database connection given");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (manifest) {
|
if (config.manifest && config.static === "assets") {
|
||||||
|
console.warn("manifest is not useful with static 'assets'");
|
||||||
|
} else if (!config.manifest && config.static === "kv") {
|
||||||
|
throw new Error("manifest is required with static 'kv'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.manifest && config.static !== "assets") {
|
||||||
const pathname = url.pathname.slice(1);
|
const pathname = url.pathname.slice(1);
|
||||||
const assetManifest = JSON.parse(manifest);
|
const assetManifest = JSON.parse(config.manifest);
|
||||||
if (pathname && pathname in assetManifest) {
|
if (pathname && pathname in assetManifest) {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
|
|
||||||
hono.all("*", async (c, next) => {
|
hono.all("*", async (c, next) => {
|
||||||
const res = await serveStatic({
|
const res = await serveStatic({
|
||||||
path: `./${pathname}`,
|
path: `./${pathname}`,
|
||||||
manifest,
|
manifest: config.manifest!
|
||||||
onNotFound: (path) => console.log("not found", path)
|
|
||||||
})(c as any, next);
|
})(c as any, next);
|
||||||
if (res instanceof Response) {
|
if (res instanceof Response) {
|
||||||
const ttl = pathname.startsWith("assets/")
|
const ttl = 60 * 60 * 24 * 365;
|
||||||
? 60 * 60 * 24 * 365 // 1 year
|
|
||||||
: 60 * 5; // 5 minutes
|
|
||||||
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -44,214 +99,21 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const context = { request, env, ctx } as Context;
|
||||||
..._config,
|
const mode = config.mode ?? "warm";
|
||||||
setAdminHtml: _config.setAdminHtml ?? !!manifest
|
|
||||||
};
|
|
||||||
const context = { request, env, ctx, manifest, html };
|
|
||||||
const mode = config.cloudflare?.mode?.(env);
|
|
||||||
|
|
||||||
if (!mode) {
|
switch (mode) {
|
||||||
console.log("serving fresh...");
|
case "fresh":
|
||||||
const app = await getFresh(config, context);
|
return await getFresh(config, context);
|
||||||
return app.fetch(request, env);
|
case "warm":
|
||||||
} else if ("cache" in mode) {
|
return await getWarm(config, context);
|
||||||
console.log("serving cached...");
|
case "cache":
|
||||||
const app = await getCached(config as any, context);
|
return await getCached(config, context);
|
||||||
return app.fetch(request, env);
|
case "durable":
|
||||||
} else if ("durableObject" in mode) {
|
return await getDurable(config, context);
|
||||||
console.log("serving durable...");
|
default:
|
||||||
|
throw new Error(`Unknown mode ${mode}`);
|
||||||
if (config.onBuilt) {
|
|
||||||
console.log("onBuilt() is not supported with DurableObject mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
const durable = mode.durableObject;
|
|
||||||
const id = durable.idFromName(mode.key);
|
|
||||||
const stub = durable.get(id) as unknown as DurableBkndApp;
|
|
||||||
|
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
|
||||||
|
|
||||||
const res = await stub.fire(request, {
|
|
||||||
config: create_config,
|
|
||||||
html,
|
|
||||||
keepAliveSeconds: mode.keepAliveSeconds,
|
|
||||||
setAdminHtml: config.setAdminHtml
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
headers.set("X-TTDO", String(performance.now() - start));
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFresh(config: BkndConfig, { env, html }: Context) {
|
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
|
||||||
const app = App.create(create_config);
|
|
||||||
|
|
||||||
if (config.onBuilt) {
|
|
||||||
app.emgr.onEvent(
|
|
||||||
App.Events.AppBuiltEvent,
|
|
||||||
async ({ params: { app } }) => {
|
|
||||||
config.onBuilt!(app);
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
if (config.setAdminHtml) {
|
|
||||||
app.registerAdminController({ html });
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCached(
|
|
||||||
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
|
|
||||||
{ env, html, ctx }: Context
|
|
||||||
) {
|
|
||||||
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
|
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
|
||||||
|
|
||||||
const cachedConfig = await cache.get(key);
|
|
||||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
|
||||||
|
|
||||||
const app = App.create({ ...create_config, initialConfig });
|
|
||||||
|
|
||||||
async function saveConfig(__config: any) {
|
|
||||||
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.onBuilt) {
|
|
||||||
app.emgr.onEvent(
|
|
||||||
App.Events.AppBuiltEvent,
|
|
||||||
async ({ params: { app } }) => {
|
|
||||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
|
||||||
await cache.delete(key);
|
|
||||||
return c.json({ message: "Cache cleared" });
|
|
||||||
});
|
|
||||||
app.registerAdminController({ html });
|
|
||||||
|
|
||||||
config.onBuilt!(app);
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.emgr.onEvent(
|
|
||||||
App.Events.AppConfigUpdatedEvent,
|
|
||||||
async ({ params: { app } }) => {
|
|
||||||
saveConfig(app.toJSON(true));
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
if (config.setAdminHtml) {
|
|
||||||
app.registerAdminController({ html });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedConfig) {
|
|
||||||
saveConfig(app.toJSON(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DurableBkndApp extends DurableObject {
|
|
||||||
protected id = Math.random().toString(36).slice(2);
|
|
||||||
protected app?: App;
|
|
||||||
protected interval?: any;
|
|
||||||
|
|
||||||
async fire(
|
|
||||||
request: Request,
|
|
||||||
options: {
|
|
||||||
config: CreateAppConfig;
|
|
||||||
html?: string;
|
|
||||||
keepAliveSeconds?: number;
|
|
||||||
setAdminHtml?: boolean;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
let buildtime = 0;
|
|
||||||
if (!this.app) {
|
|
||||||
const start = performance.now();
|
|
||||||
const config = options.config;
|
|
||||||
|
|
||||||
// change protocol to websocket if libsql
|
|
||||||
if ("type" in config.connection && config.connection.type === "libsql") {
|
|
||||||
config.connection.config.protocol = "wss";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.app = App.create(config);
|
|
||||||
this.app.emgr.onEvent(
|
|
||||||
App.Events.AppBuiltEvent,
|
|
||||||
async ({ params: { app } }) => {
|
|
||||||
app.modules.server.get("/__do", async (c) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
|
||||||
return c.json({
|
|
||||||
id: this.id,
|
|
||||||
keepAlive: options?.keepAliveSeconds,
|
|
||||||
colo: context.colo
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.app.build();
|
|
||||||
|
|
||||||
buildtime = performance.now() - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.keepAliveSeconds) {
|
|
||||||
this.keepAlive(options.keepAliveSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("id", this.id);
|
|
||||||
const res = await this.app!.fetch(request);
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
headers.set("X-BuildTime", buildtime.toString());
|
|
||||||
headers.set("X-DO-ID", this.id);
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected keepAlive(seconds: number) {
|
|
||||||
console.log("keep alive for", seconds);
|
|
||||||
if (this.interval) {
|
|
||||||
console.log("clearing, there is a new");
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
i += 1;
|
|
||||||
//console.log("keep-alive", i);
|
|
||||||
if (i === seconds) {
|
|
||||||
console.log("cleared");
|
|
||||||
clearInterval(this.interval);
|
|
||||||
|
|
||||||
// ping every 30 seconds
|
|
||||||
} else if (i % 30 === 0) {
|
|
||||||
console.log("ping");
|
|
||||||
this.app?.modules.ctx().connection.ping();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +1,18 @@
|
|||||||
|
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
|
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||||
|
export { getCached } from "./modes/cached";
|
||||||
|
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||||
|
export { D1Connection, type D1ConnectionConfig };
|
||||||
|
export {
|
||||||
|
getBinding,
|
||||||
|
getBindings,
|
||||||
|
type BindingTypeMap,
|
||||||
|
type GetBindingType,
|
||||||
|
type BindingMap
|
||||||
|
} from "./bindings";
|
||||||
|
|
||||||
|
export function d1(config: D1ConnectionConfig) {
|
||||||
|
return new D1Connection(config);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { App } from "bknd";
|
||||||
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||||
|
|
||||||
|
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
|
||||||
|
const { kv } = config.bindings?.(env)!;
|
||||||
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
|
const key = config.key ?? "app";
|
||||||
|
|
||||||
|
const cachedConfig = await kv.get(key);
|
||||||
|
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||||
|
|
||||||
|
async function saveConfig(__config: any) {
|
||||||
|
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await createRuntimeApp(
|
||||||
|
{
|
||||||
|
...makeCfConfig(config, { env, ctx, ...args }),
|
||||||
|
initialConfig,
|
||||||
|
onBuilt: async (app) => {
|
||||||
|
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||||
|
await kv.delete(key);
|
||||||
|
return c.json({ message: "Cache cleared" });
|
||||||
|
});
|
||||||
|
await config.onBuilt?.(app);
|
||||||
|
},
|
||||||
|
beforeBuild: async (app) => {
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppConfigUpdatedEvent,
|
||||||
|
async ({ params: { app } }) => {
|
||||||
|
saveConfig(app.toJSON(true));
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
await config.beforeBuild?.(app);
|
||||||
|
},
|
||||||
|
adminOptions: { html: config.html }
|
||||||
|
},
|
||||||
|
{ env, ctx, ...args }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cachedConfig) {
|
||||||
|
saveConfig(app.toJSON(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
import type { App, CreateAppConfig } from "bknd";
|
||||||
|
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||||
|
import type { CloudflareBkndConfig, Context } from "../index";
|
||||||
|
|
||||||
|
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
|
const { dobj } = config.bindings?.(ctx.env)!;
|
||||||
|
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
||||||
|
const key = config.key ?? "app";
|
||||||
|
|
||||||
|
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
||||||
|
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const id = dobj.idFromName(key);
|
||||||
|
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||||
|
|
||||||
|
const create_config = makeConfig(config, ctx);
|
||||||
|
|
||||||
|
const res = await stub.fire(ctx.request, {
|
||||||
|
config: create_config,
|
||||||
|
html: config.html,
|
||||||
|
keepAliveSeconds: config.keepAliveSeconds,
|
||||||
|
setAdminHtml: config.setAdminHtml
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers(res.headers);
|
||||||
|
headers.set("X-TTDO", String(performance.now() - start));
|
||||||
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DurableBkndApp extends DurableObject {
|
||||||
|
protected id = Math.random().toString(36).slice(2);
|
||||||
|
protected app?: App;
|
||||||
|
protected interval?: any;
|
||||||
|
|
||||||
|
async fire(
|
||||||
|
request: Request,
|
||||||
|
options: {
|
||||||
|
config: CreateAppConfig;
|
||||||
|
html?: string;
|
||||||
|
keepAliveSeconds?: number;
|
||||||
|
setAdminHtml?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let buildtime = 0;
|
||||||
|
if (!this.app) {
|
||||||
|
const start = performance.now();
|
||||||
|
const config = options.config;
|
||||||
|
|
||||||
|
// change protocol to websocket if libsql
|
||||||
|
if (
|
||||||
|
config?.connection &&
|
||||||
|
"type" in config.connection &&
|
||||||
|
config.connection.type === "libsql"
|
||||||
|
) {
|
||||||
|
config.connection.config.protocol = "wss";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.app = await createRuntimeApp({
|
||||||
|
...config,
|
||||||
|
onBuilt: async (app) => {
|
||||||
|
app.modules.server.get("/__do", async (c) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||||
|
return c.json({
|
||||||
|
id: this.id,
|
||||||
|
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
|
||||||
|
colo: context.colo
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.onBuilt(app);
|
||||||
|
},
|
||||||
|
adminOptions: { html: options.html },
|
||||||
|
beforeBuild: async (app) => {
|
||||||
|
await this.beforeBuild(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buildtime = performance.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.keepAliveSeconds) {
|
||||||
|
this.keepAlive(options.keepAliveSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("id", this.id);
|
||||||
|
const res = await this.app!.fetch(request);
|
||||||
|
const headers = new Headers(res.headers);
|
||||||
|
headers.set("X-BuildTime", buildtime.toString());
|
||||||
|
headers.set("X-DO-ID", this.id);
|
||||||
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onBuilt(app: App) {}
|
||||||
|
async beforeBuild(app: App) {}
|
||||||
|
|
||||||
|
protected keepAlive(seconds: number) {
|
||||||
|
console.log("keep alive for", seconds);
|
||||||
|
if (this.interval) {
|
||||||
|
console.log("clearing, there is a new");
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
i += 1;
|
||||||
|
//console.log("keep-alive", i);
|
||||||
|
if (i === seconds) {
|
||||||
|
console.log("cleared");
|
||||||
|
clearInterval(this.interval);
|
||||||
|
|
||||||
|
// ping every 30 seconds
|
||||||
|
} else if (i % 30 === 0) {
|
||||||
|
console.log("ping");
|
||||||
|
this.app?.modules.ctx().connection.ping();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { App } from "bknd";
|
||||||
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||||
|
|
||||||
|
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
|
return await createRuntimeApp(
|
||||||
|
{
|
||||||
|
...makeCfConfig(config, ctx),
|
||||||
|
adminOptions: config.html ? { html: config.html } : undefined
|
||||||
|
},
|
||||||
|
ctx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
|
const app = await makeApp(config, ctx);
|
||||||
|
return app.fetch(ctx.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
let warm_app: App;
|
||||||
|
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
|
if (!warm_app) {
|
||||||
|
warm_app = await makeApp(config, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warm_app.fetch(ctx.request);
|
||||||
|
}
|
||||||
+82
-52
@@ -1,62 +1,92 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import { App, type CreateAppConfig } from "bknd";
|
||||||
import type { App, CreateAppConfig } from "bknd";
|
import { config as $config } from "bknd/core";
|
||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
|
|
||||||
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||||
cache: KVNamespace;
|
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||||
key: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
|
|
||||||
durableObject: DurableObjectNamespace;
|
|
||||||
key: string;
|
|
||||||
keepAliveSeconds?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CloudflareBkndConfig<Env = any> = {
|
|
||||||
mode?: CfBkndModeCache | CfBkndModeDurableObject;
|
|
||||||
forceHttps?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @todo: move to App
|
|
||||||
export type BkndConfig<Env = any> = {
|
|
||||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
|
||||||
setAdminHtml?: boolean;
|
|
||||||
server?: {
|
|
||||||
port?: number;
|
|
||||||
platform?: "node" | "bun";
|
|
||||||
};
|
|
||||||
cloudflare?: CloudflareBkndConfig<Env>;
|
|
||||||
onBuilt?: (app: App) => Promise<void>;
|
onBuilt?: (app: App) => Promise<void>;
|
||||||
|
beforeBuild?: (app: App) => Promise<void>;
|
||||||
|
buildConfig?: Parameters<App["build"]>[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BkndConfigJson = {
|
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
app: CreateAppConfig;
|
|
||||||
setAdminHtml?: boolean;
|
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||||
server?: {
|
distPath?: string;
|
||||||
port?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
|
||||||
let protocol = "http";
|
let additionalConfig: CreateAppConfig = {};
|
||||||
try {
|
if ("app" in config && config.app) {
|
||||||
protocol = req.headers["x-forwarded-proto"] as string;
|
if (typeof config.app === "function") {
|
||||||
} catch (e) {}
|
if (!args) {
|
||||||
const host = req.headers.host;
|
throw new Error("args is required when config.app is a function");
|
||||||
const url = `${protocol}://${host}${req.url}`;
|
}
|
||||||
const headers = new Headers();
|
additionalConfig = config.app(args);
|
||||||
|
} else {
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
additionalConfig = config.app;
|
||||||
if (Array.isArray(value)) {
|
|
||||||
headers.append(key, value.join(", "));
|
|
||||||
} else if (value) {
|
|
||||||
headers.append(key, value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = req.method || "GET";
|
return { ...config, ...additionalConfig };
|
||||||
return new Request(url, {
|
}
|
||||||
method,
|
|
||||||
headers
|
export async function createFrameworkApp<Args = any>(
|
||||||
});
|
config: FrameworkBkndConfig,
|
||||||
|
args?: Args
|
||||||
|
): Promise<App> {
|
||||||
|
const app = App.create(makeConfig(config, args));
|
||||||
|
|
||||||
|
if (config.onBuilt) {
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBuiltEvent,
|
||||||
|
async () => {
|
||||||
|
await config.onBuilt?.(app);
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.beforeBuild?.(app);
|
||||||
|
await app.build(config.buildConfig);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRuntimeApp<Env = any>(
|
||||||
|
{
|
||||||
|
serveStatic,
|
||||||
|
adminOptions,
|
||||||
|
...config
|
||||||
|
}: RuntimeBkndConfig & {
|
||||||
|
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||||
|
adminOptions?: AdminControllerOptions | false;
|
||||||
|
},
|
||||||
|
env?: Env
|
||||||
|
): Promise<App> {
|
||||||
|
const app = App.create(makeConfig(config, env));
|
||||||
|
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBuiltEvent,
|
||||||
|
async () => {
|
||||||
|
if (serveStatic) {
|
||||||
|
const [path, handler] = Array.isArray(serveStatic)
|
||||||
|
? serveStatic
|
||||||
|
: [$config.server.assets_path + "*", serveStatic];
|
||||||
|
app.modules.server.get(path, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.onBuilt?.(app);
|
||||||
|
if (adminOptions !== false) {
|
||||||
|
app.registerAdminController(adminOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
await config.beforeBuild?.(app);
|
||||||
|
await app.build(config.buildConfig);
|
||||||
|
|
||||||
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { withApi } from "bknd/adapter/nextjs";
|
|
||||||
import type { BkndAdminProps } from "bknd/ui";
|
|
||||||
import type { InferGetServerSidePropsType } from "next";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
export const getServerSideProps = withApi(async (context) => {
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
user: context.api.getUser()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function adminPage(adminProps?: BkndAdminProps) {
|
|
||||||
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
|
|
||||||
return (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
|
||||||
if (typeof document === "undefined") return null;
|
|
||||||
return <Admin withProvider={{ user: props.user }} {...adminProps} />;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
export * from "./nextjs.adapter";
|
export * from "./nextjs.adapter";
|
||||||
export * from "./AdminPage";
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { Api, App, type CreateAppConfig } from "bknd";
|
import { nodeRequestToRequest } from "adapter/utils";
|
||||||
import { nodeRequestToRequest } from "../index";
|
import type { App } from "bknd";
|
||||||
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
import { Api } from "bknd/client";
|
||||||
|
|
||||||
|
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||||
|
cleanSearch?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type GetServerSidePropsContext = {
|
type GetServerSidePropsContext = {
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
@@ -18,7 +24,6 @@ type GetServerSidePropsContext = {
|
|||||||
|
|
||||||
export function createApi({ req }: GetServerSidePropsContext) {
|
export function createApi({ req }: GetServerSidePropsContext) {
|
||||||
const request = nodeRequestToRequest(req);
|
const request = nodeRequestToRequest(req);
|
||||||
//console.log("createApi:request.headers", request.headers);
|
|
||||||
return new Api({
|
return new Api({
|
||||||
host: new URL(request.url).origin,
|
host: new URL(request.url).origin,
|
||||||
headers: request.headers
|
headers: request.headers
|
||||||
@@ -26,15 +31,20 @@ export function createApi({ req }: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
||||||
return (ctx: GetServerSidePropsContext & { api: Api }) => {
|
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||||
return handler({ ...ctx, api: createApi(ctx) });
|
const api = createApi(ctx);
|
||||||
|
await api.verifyAuth();
|
||||||
|
return handler({ ...ctx, api });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCleanRequest(req: Request) {
|
function getCleanRequest(
|
||||||
// clean search params from "route" attribute
|
req: Request,
|
||||||
|
{ cleanSearch = ["route"] }: Pick<NextjsBkndConfig, "cleanSearch">
|
||||||
|
) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
url.searchParams.delete("route");
|
cleanSearch?.forEach((k) => url.searchParams.delete(k));
|
||||||
|
|
||||||
return new Request(url.toString(), {
|
return new Request(url.toString(), {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
@@ -43,13 +53,12 @@ function getCleanRequest(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: CreateAppConfig) {
|
export function serve({ cleanSearch, ...config }: NextjsBkndConfig = {}) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = App.create(config);
|
app = await createFrameworkApp(config);
|
||||||
await app.build();
|
|
||||||
}
|
}
|
||||||
const request = getCleanRequest(req);
|
const request = getCleanRequest(req, { cleanSearch });
|
||||||
return app.fetch(request, process.env);
|
return app.fetch(request, process.env);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,12 @@
|
|||||||
import path from "node:path";
|
import { registries } from "bknd";
|
||||||
import { serve as honoServe } from "@hono/node-server";
|
import {
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
type LocalAdapterConfig,
|
||||||
import { App, type CreateAppConfig } from "bknd";
|
StorageLocalAdapter
|
||||||
import { LibsqlConnection } from "bknd/data";
|
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||||
|
|
||||||
async function getConnection(conn?: CreateAppConfig["connection"]) {
|
export * from "./node.adapter";
|
||||||
if (conn) {
|
export { StorageLocalAdapter, type LocalAdapterConfig };
|
||||||
if (LibsqlConnection.isConnection(conn)) {
|
|
||||||
return conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LibsqlConnection(conn.config);
|
export function registerLocalMediaAdapter() {
|
||||||
}
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
|
|
||||||
if (!createClient) {
|
|
||||||
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Using in-memory database");
|
|
||||||
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NodeAdapterOptions = {
|
|
||||||
relativeDistPath?: string;
|
|
||||||
port?: number;
|
|
||||||
hostname?: string;
|
|
||||||
listener?: Parameters<typeof honoServe>[1];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapterOptions = {}) {
|
|
||||||
const root = path.relative(
|
|
||||||
process.cwd(),
|
|
||||||
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
|
||||||
);
|
|
||||||
let app: App;
|
|
||||||
|
|
||||||
honoServe(
|
|
||||||
{
|
|
||||||
port: options.port ?? 1337,
|
|
||||||
hostname: options.hostname,
|
|
||||||
fetch: async (req: Request) => {
|
|
||||||
if (!app) {
|
|
||||||
const connection = await getConnection(_config.connection);
|
|
||||||
app = App.create({
|
|
||||||
..._config,
|
|
||||||
connection
|
|
||||||
});
|
|
||||||
|
|
||||||
app.emgr.on(
|
|
||||||
"app-built",
|
|
||||||
async () => {
|
|
||||||
app.modules.server.get(
|
|
||||||
"/*",
|
|
||||||
serveStatic({
|
|
||||||
root
|
|
||||||
})
|
|
||||||
);
|
|
||||||
app.registerAdminController();
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.fetch(req);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options.listener
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { serve as honoServe } from "@hono/node-server";
|
||||||
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { registerLocalMediaAdapter } from "adapter/node/index";
|
||||||
|
import type { App } from "bknd";
|
||||||
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { config as $config } from "bknd/core";
|
||||||
|
|
||||||
|
export type NodeBkndConfig = RuntimeBkndConfig & {
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
listener?: Parameters<typeof honoServe>[1];
|
||||||
|
/** @deprecated */
|
||||||
|
relativeDistPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serve({
|
||||||
|
distPath,
|
||||||
|
relativeDistPath,
|
||||||
|
port = $config.server.default_port,
|
||||||
|
hostname,
|
||||||
|
listener,
|
||||||
|
...config
|
||||||
|
}: NodeBkndConfig = {}) {
|
||||||
|
const root = path.relative(
|
||||||
|
process.cwd(),
|
||||||
|
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
||||||
|
);
|
||||||
|
if (relativeDistPath) {
|
||||||
|
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
honoServe(
|
||||||
|
{
|
||||||
|
port,
|
||||||
|
hostname,
|
||||||
|
fetch: async (req: Request) => {
|
||||||
|
if (!app) {
|
||||||
|
registerLocalMediaAdapter();
|
||||||
|
app = await createRuntimeApp({
|
||||||
|
...config,
|
||||||
|
serveStatic: serveStatic({ root })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.fetch(req);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(connInfo) => {
|
||||||
|
console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||||
|
listener?.(connInfo);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useAuth } from "bknd/client";
|
||||||
import type { BkndAdminProps } from "bknd/ui";
|
import type { BkndAdminProps } from "bknd/ui";
|
||||||
import { Suspense, lazy, useEffect, useState } from "react";
|
import { Suspense, lazy, useEffect, useState } from "react";
|
||||||
|
|
||||||
export function adminPage(props?: BkndAdminProps) {
|
export function adminPage(props?: BkndAdminProps) {
|
||||||
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
||||||
return () => {
|
return () => {
|
||||||
|
const auth = useAuth();
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Admin {...props} />
|
<Admin withProvider={{ user: auth.user }} {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { App, type CreateAppConfig } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
|
||||||
|
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
|
||||||
|
|
||||||
|
type RemixContext = {
|
||||||
|
request: Request;
|
||||||
|
};
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: CreateAppConfig) {
|
let building: boolean = false;
|
||||||
return async (args: { request: Request }) => {
|
|
||||||
if (!app) {
|
export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
|
||||||
app = App.create(config);
|
if (building) {
|
||||||
await app.build();
|
while (building) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
if (app) return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
if (!app) {
|
||||||
|
app = await createFrameworkApp(config, args);
|
||||||
|
await app.build();
|
||||||
|
}
|
||||||
|
building = false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve<Args extends RemixContext = RemixContext>(
|
||||||
|
config: RemixBkndConfig<Args> = {}
|
||||||
|
) {
|
||||||
|
return async (args: Args) => {
|
||||||
|
app = await createFrameworkApp(config, args);
|
||||||
return app.fetch(args.request);
|
return app.fetch(args.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||||
|
let protocol = "http";
|
||||||
|
try {
|
||||||
|
protocol = req.headers["x-forwarded-proto"] as string;
|
||||||
|
} catch (e) {}
|
||||||
|
const host = req.headers.host;
|
||||||
|
const url = `${protocol}://${host}${req.url}`;
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
headers.append(key, value.join(", "));
|
||||||
|
} else if (value) {
|
||||||
|
headers.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method || "GET";
|
||||||
|
return new Request(url, {
|
||||||
|
method,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const devServerConfig = {
|
||||||
|
entry: "./server.ts",
|
||||||
|
exclude: [
|
||||||
|
/.*\.tsx?($|\?)/,
|
||||||
|
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
|
||||||
|
// exclude except /api
|
||||||
|
/^(?!.*\/api).*\.(ico|mp4|jpg|jpeg|svg|png|vtt|mp3|js)($|\?)/,
|
||||||
|
/^\/@.+$/,
|
||||||
|
/\/components.*?\.json.*/, // @todo: improve
|
||||||
|
/^\/(public|assets|static)\/.+/,
|
||||||
|
/^\/node_modules\/.*/
|
||||||
|
] as any,
|
||||||
|
injectClientScript: false
|
||||||
|
} as const;
|
||||||
@@ -1,50 +1,82 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import type { BkndConfig } from "bknd";
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
import { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
|
import { devServerConfig } from "./dev-server-config";
|
||||||
|
|
||||||
function createApp(config: BkndConfig, env: any) {
|
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
mode?: "cached" | "fresh";
|
||||||
return App.create(create_config);
|
setAdminHtml?: boolean;
|
||||||
}
|
forceDev?: boolean | { mainPath: string };
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function setAppBuildListener(app: App, config: BkndConfig, html?: string) {
|
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||||
app.emgr.on(
|
return html.replace(
|
||||||
"app-built",
|
"</head>",
|
||||||
async () => {
|
`<script type="module">
|
||||||
await config.onBuilt?.(app);
|
import RefreshRuntime from "/@react-refresh"
|
||||||
if (config.setAdminHtml) {
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
app.registerAdminController({ html, forceDev: true });
|
window.$RefreshReg$ = () => {}
|
||||||
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
window.$RefreshSig$ = () => (type) => type
|
||||||
}
|
window.__vite_plugin_react_preamble_installed__ = true
|
||||||
},
|
</script>
|
||||||
"sync"
|
<script type="module" src="/@vite/client"></script>
|
||||||
|
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||||
|
</head>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||||
|
registerLocalMediaAdapter();
|
||||||
|
return await createRuntimeApp(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
adminOptions:
|
||||||
|
config.setAdminHtml === false
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
html: config.html,
|
||||||
|
forceDev: config.forceDev ?? {
|
||||||
|
mainPath: "/src/main.tsx"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
|
||||||
|
},
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
const app = createApp(config, env);
|
const app = await createApp(config, env);
|
||||||
|
|
||||||
setAppBuildListener(app, config, _html);
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export async function serveCached(config: BkndConfig, _html?: string) {
|
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = createApp(config, env);
|
app = await createApp(config, env);
|
||||||
setAppBuildListener(app, config, _html);
|
|
||||||
await app.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
|
||||||
|
return mode === "fresh" ? serveFresh(config) : serveCached(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function devServer(options: DevServerOptions) {
|
||||||
|
return honoViteDevServer({
|
||||||
|
...devServerConfig,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+87
-61
@@ -1,21 +1,30 @@
|
|||||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
import {
|
||||||
import { Exception } from "core";
|
type AuthAction,
|
||||||
|
AuthPermissions,
|
||||||
|
Authenticator,
|
||||||
|
type ProfileExchange,
|
||||||
|
Role,
|
||||||
|
type Strategy
|
||||||
|
} from "auth";
|
||||||
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { AuthController } from "./api/AuthController";
|
import { AuthController } from "./api/AuthController";
|
||||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||||
|
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare global {
|
declare module "core" {
|
||||||
interface DB {
|
interface DB {
|
||||||
users: UserFieldSchema;
|
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthSchema = Static<typeof authConfigSchema>;
|
type AuthSchema = Static<typeof authConfigSchema>;
|
||||||
|
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||||
|
|
||||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||||
private _authenticator?: Authenticator;
|
private _authenticator?: Authenticator;
|
||||||
@@ -35,8 +44,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
if (!this.config.enabled) {
|
if (!this.enabled) {
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,8 +84,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
super.setBuilt();
|
super.setBuilt();
|
||||||
|
|
||||||
this._controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
//this.ctx.server.use(controller.getMiddleware);
|
|
||||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||||
|
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
|
||||||
}
|
}
|
||||||
|
|
||||||
get controller(): AuthController {
|
get controller(): AuthController {
|
||||||
@@ -83,14 +96,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this._controller;
|
return this._controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMiddleware() {
|
|
||||||
if (!this.config.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AuthController(this).getMiddleware;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return authConfigSchema;
|
return authConfigSchema;
|
||||||
}
|
}
|
||||||
@@ -100,7 +105,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this._authenticator!;
|
return this._authenticator!;
|
||||||
}
|
}
|
||||||
|
|
||||||
get em(): EntityManager<DB> {
|
get em(): EntityManager {
|
||||||
return this.ctx.em as any;
|
return this.ctx.em as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +115,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
console.log("***** AppAuth:resolveUser", {
|
/*console.log("***** AppAuth:resolveUser", {
|
||||||
action,
|
action,
|
||||||
strategy: strategy.getName(),
|
strategy: strategy.getName(),
|
||||||
identifier,
|
identifier,
|
||||||
profile
|
profile
|
||||||
});
|
});*/
|
||||||
if (!this.config.allow_register && action === "register") {
|
if (!this.config.allow_register && action === "register") {
|
||||||
throw new Exception("Registration is not allowed", 403);
|
throw new Exception("Registration is not allowed", 403);
|
||||||
}
|
}
|
||||||
@@ -136,12 +141,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterUserData(user: any) {
|
private filterUserData(user: any) {
|
||||||
console.log(
|
/*console.log(
|
||||||
"--filterUserData",
|
"--filterUserData",
|
||||||
user,
|
user,
|
||||||
this.config.jwt.fields,
|
this.config.jwt.fields,
|
||||||
pick(user, this.config.jwt.fields)
|
pick(user, this.config.jwt.fields)
|
||||||
);
|
);*/
|
||||||
return pick(user, this.config.jwt.fields);
|
return pick(user, this.config.jwt.fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,23 +165,25 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
const users = this.getUsersEntity();
|
const users = this.getUsersEntity();
|
||||||
this.toggleStrategyValueVisibility(true);
|
this.toggleStrategyValueVisibility(true);
|
||||||
const result = await this.em.repo(users).findOne({ email: profile.email! });
|
const result = await this.em
|
||||||
|
.repo(users as unknown as "users")
|
||||||
|
.findOne({ email: profile.email! });
|
||||||
this.toggleStrategyValueVisibility(false);
|
this.toggleStrategyValueVisibility(false);
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Exception("User not found", 404);
|
throw new Exception("User not found", 404);
|
||||||
}
|
}
|
||||||
console.log("---login data", result.data, result);
|
//console.log("---login data", result.data, result);
|
||||||
|
|
||||||
// compare strategy and identifier
|
// compare strategy and identifier
|
||||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||||
if (result.data.strategy !== strategy.getName()) {
|
if (result.data.strategy !== strategy.getName()) {
|
||||||
console.log("!!! User registered with different strategy");
|
//console.log("!!! User registered with different strategy");
|
||||||
throw new Exception("User registered with different strategy");
|
throw new Exception("User registered with different strategy");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
//console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||||
if (result.data.strategy_value !== identifier) {
|
if (result.data.strategy_value !== identifier) {
|
||||||
console.log("!!! Invalid credentials");
|
//console.log("!!! Invalid credentials");
|
||||||
throw new Exception("Invalid credentials");
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +204,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
throw new Exception("User already exists");
|
throw new Exception("User already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload: any = {
|
||||||
...profile,
|
...profile,
|
||||||
strategy: strategy.getName(),
|
strategy: strategy.getName(),
|
||||||
strategy_value: identifier
|
strategy_value: identifier
|
||||||
@@ -217,10 +224,23 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toggleStrategyValueVisibility(visible: boolean) {
|
private toggleStrategyValueVisibility(visible: boolean) {
|
||||||
const field = this.getUsersEntity().field("strategy_value")!;
|
const toggle = (name: string, visible: boolean) => {
|
||||||
|
const field = this.getUsersEntity().field(name)!;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
field.config.hidden = false;
|
||||||
|
field.config.fillable = true;
|
||||||
|
} else {
|
||||||
|
// reset to normal
|
||||||
|
const template = AppAuth.usersFields.strategy_value.config;
|
||||||
|
field.config.hidden = template.hidden;
|
||||||
|
field.config.fillable = template.fillable;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle("strategy_value", visible);
|
||||||
|
toggle("strategy", visible);
|
||||||
|
|
||||||
field.config.hidden = !visible;
|
|
||||||
field.config.fillable = visible;
|
|
||||||
// @todo: think about a PasswordField that automatically hashes on save?
|
// @todo: think about a PasswordField that automatically hashes on save?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
static usersFields = {
|
static usersFields = {
|
||||||
email: text().required(),
|
email: text().required(),
|
||||||
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
|
strategy: text({
|
||||||
|
fillable: ["create"],
|
||||||
|
hidden: ["update", "form"]
|
||||||
|
}).required(),
|
||||||
strategy_value: text({
|
strategy_value: text({
|
||||||
fillable: ["create"],
|
fillable: ["create"],
|
||||||
hidden: ["read", "table", "update", "form"]
|
hidden: ["read", "table", "update", "form"]
|
||||||
@@ -244,46 +267,49 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
registerEntities() {
|
registerEntities() {
|
||||||
const users = this.getUsersEntity();
|
const users = this.getUsersEntity(true);
|
||||||
|
this.ensureSchema(
|
||||||
if (!this.em.hasEntity(users.name)) {
|
em(
|
||||||
this.em.addEntity(users);
|
{
|
||||||
} else {
|
[users.name as "users"]: users
|
||||||
// if exists, check all fields required are there
|
},
|
||||||
// @todo: add to context: "needs sync" flag
|
({ index }, { users }) => {
|
||||||
const _entity = this.getUsersEntity(true);
|
index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
|
||||||
for (const field of _entity.fields) {
|
|
||||||
const _field = users.field(field.name);
|
|
||||||
if (!_field) {
|
|
||||||
users.addField(field);
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
const indices = [
|
|
||||||
new EntityIndex(users, [users.field("email")!], true),
|
|
||||||
new EntityIndex(users, [users.field("strategy")!]),
|
|
||||||
new EntityIndex(users, [users.field("strategy_value")!])
|
|
||||||
];
|
|
||||||
indices.forEach((index) => {
|
|
||||||
if (!this.em.hasIndex(index)) {
|
|
||||||
this.em.addIndex(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = Object.keys(this.config.roles ?? {});
|
const roles = Object.keys(this.config.roles ?? {});
|
||||||
const field = make("role", enumm({ enum: roles }));
|
this.replaceEntityField(users, "role", enumm({ enum: roles }));
|
||||||
this.em.entity(users.name).__experimental_replaceField("role", field);
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const strategies = Object.keys(this.config.strategies ?? {});
|
const strategies = Object.keys(this.config.strategies ?? {});
|
||||||
const field = make("strategy", enumm({ enum: strategies }));
|
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
||||||
this.em.entity(users.name).__experimental_replaceField("strategy", field);
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = "password";
|
||||||
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
|
const strategy_value = await pw.hash(password);
|
||||||
|
const mutator = this.em.mutator(this.config.entity_name as "users");
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
|
const { data: created } = await mutator.insertOne({
|
||||||
|
...(additional as any),
|
||||||
|
email,
|
||||||
|
strategy,
|
||||||
|
strategy_value
|
||||||
|
});
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||||
if (!this.config.enabled) {
|
if (!this.config.enabled) {
|
||||||
return this.configDefault;
|
return this.configDefault;
|
||||||
|
|||||||
+44
-13
@@ -1,4 +1,5 @@
|
|||||||
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
|
import type { AuthActionResponse } from "auth/api/AuthController";
|
||||||
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
||||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||||
|
|
||||||
@@ -13,27 +14,57 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async login(strategy: string, input: any) {
|
||||||
|
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok && res.body.token) {
|
||||||
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(strategy: string, input: any) {
|
||||||
|
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok && res.body.token) {
|
||||||
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async actionSchema(strategy: string, action: string) {
|
||||||
|
return this.get<Strategy>([strategy, "actions", action, "schema.json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async action(strategy: string, action: string, input: any) {
|
||||||
|
return this.post<AuthActionResponse>([strategy, "actions", action], input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use login("password", ...) instead
|
||||||
|
* @param input
|
||||||
|
*/
|
||||||
async loginWithPassword(input: any) {
|
async loginWithPassword(input: any) {
|
||||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
return this.login("password", input);
|
||||||
if (res.res.ok && res.body.token) {
|
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use register("password", ...) instead
|
||||||
|
* @param input
|
||||||
|
*/
|
||||||
async registerWithPassword(input: any) {
|
async registerWithPassword(input: any) {
|
||||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
return this.register("password", input);
|
||||||
if (res.res.ok && res.body.token) {
|
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async me() {
|
me() {
|
||||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async strategies() {
|
strategies() {
|
||||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,119 @@
|
|||||||
import type { AppAuth } from "auth";
|
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||||
import { type ClassController, isDebug } from "core";
|
import { TypeInvalidError, parse } from "core/utils";
|
||||||
import { Hono, type MiddlewareHandler } from "hono";
|
import { DataPermissions } from "data";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { Controller } from "modules/Controller";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
export class AuthController implements ClassController {
|
export type AuthActionResponse = {
|
||||||
constructor(private auth: AppAuth) {}
|
success: boolean;
|
||||||
|
action: string;
|
||||||
|
data?: SafeUser;
|
||||||
|
errors?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AuthController extends Controller {
|
||||||
|
constructor(private auth: AppAuth) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
get guard() {
|
get guard() {
|
||||||
return this.auth.ctx.guard;
|
return this.auth.ctx.guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
get em() {
|
||||||
// @todo: ONLY HOTFIX
|
return this.auth.ctx.em;
|
||||||
// middlewares are added for all routes are registered. But we need to make sure that
|
}
|
||||||
// only HTML/JSON routes are adding a cookie to the response. Config updates might
|
|
||||||
// also use an extension "syntax", e.g. /api/system/patch/data/entities.posts
|
get userRepo() {
|
||||||
// This middleware should be extracted and added by each Controller individually,
|
const entity_name = this.auth.config.entity_name;
|
||||||
// but it requires access to the auth secret.
|
return this.em.repo(entity_name as "users");
|
||||||
// Note: This doesn't mean endpoints aren't protected, just the cookie is not set.
|
}
|
||||||
const url = new URL(c.req.url);
|
|
||||||
const last = url.pathname.split("/")?.pop();
|
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
||||||
const ext = last?.includes(".") ? last.split(".")?.pop() : undefined;
|
const actions = strategy.getActions?.();
|
||||||
if (
|
if (!actions) {
|
||||||
!this.auth.authenticator.isJsonRequest(c) &&
|
return;
|
||||||
["GET", "HEAD", "OPTIONS"].includes(c.req.method) &&
|
|
||||||
ext &&
|
|
||||||
["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext)
|
|
||||||
) {
|
|
||||||
isDebug() && console.log("Skipping auth", { ext }, url.pathname);
|
|
||||||
} else {
|
|
||||||
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
|
||||||
this.auth.ctx.guard.setUserContext(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
const { auth, permission } = this.middlewares;
|
||||||
};
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
getController(): Hono<any> {
|
const name = strategy.getName();
|
||||||
const hono = new Hono();
|
const { create, change } = actions;
|
||||||
|
const em = this.auth.em;
|
||||||
|
|
||||||
|
if (create) {
|
||||||
|
hono.post(
|
||||||
|
"/create",
|
||||||
|
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await this.auth.authenticator.getBody(c);
|
||||||
|
const valid = parse(create.schema, body, {
|
||||||
|
skipMark: true
|
||||||
|
});
|
||||||
|
const processed = (await create.preprocess?.(valid)) ?? valid;
|
||||||
|
|
||||||
|
// @todo: check processed for "role" and check permissions
|
||||||
|
const mutator = em.mutator(this.auth.config.entity_name as "users");
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
|
const { data: created } = await mutator.insertOne({
|
||||||
|
...processed,
|
||||||
|
strategy: name
|
||||||
|
});
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
action: "create",
|
||||||
|
strategy: name,
|
||||||
|
data: created as unknown as SafeUser
|
||||||
|
} as AuthActionResponse);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TypeInvalidError) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
errors: e.errors
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
hono.get("create/schema.json", async (c) => {
|
||||||
|
return c.json(create.schema);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getController() {
|
||||||
|
const { auth } = this.middlewares;
|
||||||
|
const hono = this.create();
|
||||||
const strategies = this.auth.authenticator.getStrategies();
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
//console.log("registering", name, "at", `/${name}`);
|
//console.log("registering", name, "at", `/${name}`);
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
hono.get("/me", async (c) => {
|
hono.get("/me", auth(), async (c) => {
|
||||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
const claims = c.get("auth")?.user;
|
||||||
return c.json({ user: await this.auth.authenticator.getUser() });
|
if (claims) {
|
||||||
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
return c.json({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
return c.json({ user: null }, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.get("/logout", async (c) => {
|
hono.get("/logout", auth(), async (c) => {
|
||||||
await this.auth.authenticator.logout(c);
|
await this.auth.authenticator.logout(c);
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -71,6 +132,6 @@ export class AuthController implements ClassController {
|
|||||||
return c.json({ strategies, basepath });
|
return c.json({ strategies, basepath });
|
||||||
});
|
});
|
||||||
|
|
||||||
return hono;
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Permission } from "core";
|
||||||
|
|
||||||
|
export const createUser = new Permission("auth.user.create");
|
||||||
|
//export const updateUser = new Permission("auth.user.update");
|
||||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
|||||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||||
|
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||||
|
|
||||||
const guardConfigSchema = Type.Object({
|
const guardConfigSchema = Type.Object({
|
||||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { Exception } from "core";
|
import { type DB, Exception } from "core";
|
||||||
import { addFlashMessage } from "core/server/flash";
|
import { addFlashMessage } from "core/server/flash";
|
||||||
import {
|
import {
|
||||||
type Static,
|
type Static,
|
||||||
StringEnum,
|
StringEnum,
|
||||||
type TSchema,
|
type TObject,
|
||||||
Type,
|
Type,
|
||||||
parse,
|
parse,
|
||||||
randomString,
|
runtimeSupports,
|
||||||
transformObject
|
transformObject
|
||||||
} from "core/utils";
|
} from "core/utils";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
import { sign, verify } from "hono/jwt";
|
import { sign, verify } from "hono/jwt";
|
||||||
import type { CookieOptions } from "hono/utils/cookie";
|
import type { CookieOptions } from "hono/utils/cookie";
|
||||||
import { omit } from "lodash-es";
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
export type JWTPayload = Parameters<typeof sign>[0];
|
export type JWTPayload = Parameters<typeof sign>[0];
|
||||||
|
|
||||||
|
export const strategyActions = ["create", "change"] as const;
|
||||||
|
export type StrategyActionName = (typeof strategyActions)[number];
|
||||||
|
export type StrategyAction<S extends TObject = TObject> = {
|
||||||
|
schema: S;
|
||||||
|
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||||
|
};
|
||||||
|
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
||||||
|
|
||||||
// @todo: add schema to interface to ensure proper inference
|
// @todo: add schema to interface to ensure proper inference
|
||||||
export interface Strategy {
|
export interface Strategy {
|
||||||
getController: (auth: Authenticator) => Hono<any>;
|
getController: (auth: Authenticator) => Hono<any>;
|
||||||
@@ -25,6 +33,7 @@ export interface Strategy {
|
|||||||
getMode: () => "form" | "external";
|
getMode: () => "form" | "external";
|
||||||
getName: () => string;
|
getName: () => string;
|
||||||
toJSON: (secrets?: boolean) => any;
|
toJSON: (secrets?: boolean) => any;
|
||||||
|
getActions?: () => StrategyActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
@@ -67,6 +76,9 @@ export const cookieConfig = Type.Partial(
|
|||||||
{ default: {}, additionalProperties: false }
|
{ default: {}, additionalProperties: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||||
|
// see auth.integration test for further details
|
||||||
|
|
||||||
export const jwtConfig = Type.Object(
|
export const jwtConfig = Type.Object(
|
||||||
{
|
{
|
||||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||||
@@ -94,11 +106,15 @@ export type AuthUserResolver = (
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
) => Promise<SafeUser | undefined>;
|
) => Promise<SafeUser | undefined>;
|
||||||
|
type AuthClaims = SafeUser & {
|
||||||
|
iat: number;
|
||||||
|
iss?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||||
private readonly strategies: Strategies;
|
private readonly strategies: Strategies;
|
||||||
private readonly config: AuthConfig;
|
private readonly config: AuthConfig;
|
||||||
private _user: SafeUser | undefined;
|
|
||||||
private readonly userResolver: AuthUserResolver;
|
private readonly userResolver: AuthUserResolver;
|
||||||
|
|
||||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||||
@@ -130,19 +146,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return this.strategies;
|
return this.strategies;
|
||||||
}
|
}
|
||||||
|
|
||||||
isUserLoggedIn(): boolean {
|
|
||||||
return this._user !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUser() {
|
|
||||||
return this._user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @todo: determine what to do exactly
|
|
||||||
__setUserNull() {
|
|
||||||
this._user = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
strategy<
|
strategy<
|
||||||
StrategyName extends keyof Strategies,
|
StrategyName extends keyof Strategies,
|
||||||
Strat extends Strategy = Strategies[StrategyName]
|
Strat extends Strategy = Strategies[StrategyName]
|
||||||
@@ -154,6 +157,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @todo: add jwt tests
|
||||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||||
const prohibited = ["password"];
|
const prohibited = ["password"];
|
||||||
for (const prop of prohibited) {
|
for (const prop of prohibited) {
|
||||||
@@ -185,7 +189,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(jwt: string): Promise<boolean> {
|
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||||
try {
|
try {
|
||||||
const payload = await verify(
|
const payload = await verify(
|
||||||
jwt,
|
jwt,
|
||||||
@@ -200,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
return payload as any;
|
||||||
return true;
|
} catch (e) {}
|
||||||
} catch (e) {
|
|
||||||
this._user = undefined;
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get cookieOptions(): CookieOptions {
|
private get cookieOptions(): CookieOptions {
|
||||||
@@ -220,36 +220,47 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||||
const secret = this.config.jwt.secret;
|
try {
|
||||||
|
const secret = this.config.jwt.secret;
|
||||||
|
const token = await getSignedCookie(c, secret, "auth");
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
console.error("[Error:getAuthCookie]", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
const token = await getSignedCookie(c, secret, "auth");
|
|
||||||
if (typeof token !== "string") {
|
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestCookieRefresh(c: Context) {
|
async requestCookieRefresh(c: Context<ServerEnv>) {
|
||||||
if (this.config.cookie.renew) {
|
if (this.config.cookie.renew && c.get("auth")?.user) {
|
||||||
const token = await this.getAuthCookie(c);
|
const token = await this.getAuthCookie(c);
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log("renewing cookie", c.req.url);
|
|
||||||
await this.setAuthCookie(c, token);
|
await this.setAuthCookie(c, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setAuthCookie(c: Context, token: string) {
|
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
|
||||||
const secret = this.config.jwt.secret;
|
const secret = this.config.jwt.secret;
|
||||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(c: Context) {
|
private async deleteAuthCookie(c: Context) {
|
||||||
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(c: Context<ServerEnv>) {
|
||||||
|
c.set("auth", undefined);
|
||||||
|
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
await this.deleteAuthCookie(c);
|
||||||
await addFlashMessage(c, "Signed out", "info");
|
await addFlashMessage(c, "Signed out", "info");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,27 +271,54 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return c.req.header("Content-Type") === "application/json";
|
return c.req.header("Content-Type") === "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
async getBody(c: Context) {
|
||||||
if (this.isJsonRequest(c)) {
|
if (this.isJsonRequest(c)) {
|
||||||
return c.json(data);
|
return await c.req.json();
|
||||||
|
} else {
|
||||||
|
return Object.fromEntries((await c.req.formData()).entries());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSafeUrl(c: Context, path: string) {
|
||||||
|
const p = path.replace(/\/+$/, "/");
|
||||||
|
|
||||||
|
// nextjs doesn't support non-fq urls
|
||||||
|
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
||||||
|
if (!runtimeSupports("redirects_non_fq")) {
|
||||||
|
return new URL(c.req.url).origin + p;
|
||||||
}
|
}
|
||||||
|
|
||||||
const successPath = this.config.cookie.pathSuccess ?? "/";
|
return p;
|
||||||
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
|
}
|
||||||
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
|
|
||||||
|
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||||
|
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
|
||||||
|
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||||
|
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||||
|
|
||||||
if ("token" in data) {
|
if ("token" in data) {
|
||||||
await this.setAuthCookie(c, data.token);
|
await this.setAuthCookie(c, data.token);
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c)) {
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
// can't navigate to "/" – doesn't work on nextjs
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
|
//console.log("auth success, redirecting to", successUrl);
|
||||||
return c.redirect(successUrl);
|
return c.redirect(successUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c)) {
|
||||||
|
return c.json(data, 400);
|
||||||
|
}
|
||||||
|
|
||||||
let message = "An error occured";
|
let message = "An error occured";
|
||||||
if (data instanceof Exception) {
|
if (data instanceof Exception) {
|
||||||
message = data.message;
|
message = data.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
await addFlashMessage(c, message, "error");
|
await addFlashMessage(c, message, "error");
|
||||||
|
//console.log("auth failed, redirecting to", referer);
|
||||||
return c.redirect(referer);
|
return c.redirect(referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,8 +333,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.verify(token);
|
return await this.verify(token);
|
||||||
return this._user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -310,3 +347,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createStrategyAction<S extends TObject>(
|
||||||
|
schema: S,
|
||||||
|
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
schema,
|
||||||
|
preprocess
|
||||||
|
} as StrategyAction<S>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Authenticator, Strategy } from "auth";
|
import type { Authenticator, Strategy } from "auth";
|
||||||
|
import { isDebug, tbValidator as tb } from "core";
|
||||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||||
import { hash } from "core/utils";
|
import { hash } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
|
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
|
||||||
|
|
||||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||||
@@ -54,35 +56,78 @@ export class PasswordStrategy implements Strategy {
|
|||||||
getController(authenticator: Authenticator): Hono<any> {
|
getController(authenticator: Authenticator): Hono<any> {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
|
|
||||||
async function getBody(c: Context) {
|
|
||||||
if (authenticator.isJsonRequest(c)) {
|
|
||||||
return await c.req.json();
|
|
||||||
} else {
|
|
||||||
return Object.fromEntries((await c.req.formData()).entries());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hono
|
return hono
|
||||||
.post("/login", async (c) => {
|
.post(
|
||||||
const body = await getBody(c);
|
"/login",
|
||||||
|
tb(
|
||||||
|
"query",
|
||||||
|
Type.Object({
|
||||||
|
redirect: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = await authenticator.getBody(c);
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.login(body);
|
const payload = await this.login(body);
|
||||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
const data = await authenticator.resolve(
|
||||||
|
"login",
|
||||||
|
this,
|
||||||
|
payload.password,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
return await authenticator.respond(c, data);
|
return await authenticator.respond(c, data, redirect);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return await authenticator.respond(c, e);
|
return await authenticator.respond(c, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
.post("/register", async (c) => {
|
.post(
|
||||||
const body = await getBody(c);
|
"/register",
|
||||||
|
tb(
|
||||||
|
"query",
|
||||||
|
Type.Object({
|
||||||
|
redirect: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = await authenticator.getBody(c);
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
const payload = await this.register(body);
|
const payload = await this.register(body);
|
||||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
const data = await authenticator.resolve(
|
||||||
|
"register",
|
||||||
|
this,
|
||||||
|
payload.password,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
return await authenticator.respond(c, data);
|
return await authenticator.respond(c, data, redirect);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions(): StrategyActions {
|
||||||
|
return {
|
||||||
|
create: createStrategyAction(
|
||||||
|
Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8 // @todo: this should be configurable
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
async ({ password, ...input }) => {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
strategy_value: await this.hash(password)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { Exception, Permission } from "core";
|
import { Exception, Permission } from "core";
|
||||||
import { objectTransform } from "core/utils";
|
import { objectTransform } from "core/utils";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
import { Role } from "./Role";
|
import { Role } from "./Role";
|
||||||
|
|
||||||
export type GuardUserContext = {
|
export type GuardUserContext = {
|
||||||
role: string | null | undefined;
|
role?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuardConfig = {
|
export type GuardConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||||
|
|
||||||
const debug = false;
|
const debug = false;
|
||||||
|
|
||||||
export class Guard {
|
export class Guard {
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
user?: GuardUserContext;
|
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
config?: GuardConfig;
|
config?: GuardConfig;
|
||||||
|
|
||||||
@@ -89,21 +91,20 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserContext(user: GuardUserContext | undefined) {
|
getUserRole(user?: GuardUserContext): Role | undefined {
|
||||||
this.user = user;
|
if (user && typeof user.role === "string") {
|
||||||
return this;
|
const role = this.roles?.find((role) => role.name === user?.role);
|
||||||
}
|
|
||||||
|
|
||||||
getUserRole(): Role | undefined {
|
|
||||||
if (this.user && typeof this.user.role === "string") {
|
|
||||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
|
||||||
if (role) {
|
if (role) {
|
||||||
debug && console.log("guard: role found", this.user.role);
|
debug && console.log("guard: role found", [user.role]);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug && console.log("guard: role not found", this.user, this.user?.role);
|
debug &&
|
||||||
|
console.log("guard: role not found", {
|
||||||
|
user: user,
|
||||||
|
role: user?.role
|
||||||
|
});
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,9 +116,9 @@ export class Guard {
|
|||||||
return this.config?.enabled === true;
|
return this.config?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission(permission: Permission): boolean;
|
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
|
||||||
hasPermission(name: string): boolean;
|
hasPermission(name: string, user?: GuardUserContext): boolean;
|
||||||
hasPermission(permissionOrName: Permission | string): boolean {
|
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
//console.log("guard not enabled, allowing");
|
//console.log("guard not enabled, allowing");
|
||||||
return true;
|
return true;
|
||||||
@@ -129,7 +130,7 @@ export class Guard {
|
|||||||
throw new Error(`Permission ${name} does not exist`);
|
throw new Error(`Permission ${name} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = this.getUserRole();
|
const role = this.getUserRole(user);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
debug && console.log("guard: role not found, denying");
|
debug && console.log("guard: role not found, denying");
|
||||||
@@ -152,12 +153,13 @@ export class Guard {
|
|||||||
return !!rolePermission;
|
return !!rolePermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
granted(permission: Permission | string): boolean {
|
granted(permission: Permission | string, c?: GuardContext): boolean {
|
||||||
return this.hasPermission(permission as any);
|
const user = c && "get" in c ? c.get("auth")?.user : c;
|
||||||
|
return this.hasPermission(permission as any, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
throwUnlessGranted(permission: Permission | string) {
|
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
|
||||||
if (!this.granted(permission)) {
|
if (!this.granted(permission, c)) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth";
|
|||||||
|
|
||||||
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
|
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
|
||||||
export { Role } from "./authorize/Role";
|
export { Role } from "./authorize/Role";
|
||||||
|
|
||||||
|
export * as AuthPermissions from "./auth-permissions";
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Permission } from "core";
|
||||||
|
import { patternMatch } from "core/utils";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
|
function getPath(reqOrCtx: Request | Context) {
|
||||||
|
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||||
|
return new URL(req.url).pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||||
|
const authCtx = c.get("auth");
|
||||||
|
if (!authCtx) {
|
||||||
|
throw new Error("auth ctx not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCtx.skip) return true;
|
||||||
|
|
||||||
|
const req = c.req.raw;
|
||||||
|
if (!skip) return false;
|
||||||
|
|
||||||
|
const path = getPath(req);
|
||||||
|
const result = skip.some((s) => patternMatch(path, s));
|
||||||
|
|
||||||
|
authCtx.skip = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = (options?: {
|
||||||
|
skip?: (string | RegExp)[];
|
||||||
|
}) =>
|
||||||
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
|
if (!c.get("auth")) {
|
||||||
|
c.set("auth", {
|
||||||
|
registered: false,
|
||||||
|
resolved: false,
|
||||||
|
skip: false,
|
||||||
|
user: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = c.get("app");
|
||||||
|
const authCtx = c.get("auth")!;
|
||||||
|
const authenticator = app?.module.auth.authenticator;
|
||||||
|
|
||||||
|
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||||
|
|
||||||
|
// make sure to only register once
|
||||||
|
if (authCtx.registered) {
|
||||||
|
skipped = true;
|
||||||
|
console.warn(`auth middleware already registered for ${getPath(c)}`);
|
||||||
|
} else {
|
||||||
|
authCtx.registered = true;
|
||||||
|
|
||||||
|
if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
|
||||||
|
authCtx.user = await authenticator?.resolveAuthFromRequest(c);
|
||||||
|
authCtx.resolved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (!skipped) {
|
||||||
|
// renew cookie if applicable
|
||||||
|
authenticator?.requestCookieRefresh(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release
|
||||||
|
authCtx.skip = false;
|
||||||
|
authCtx.resolved = false;
|
||||||
|
authCtx.user = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permission = (
|
||||||
|
permission: Permission | Permission[],
|
||||||
|
options?: {
|
||||||
|
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||||
|
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
// @ts-ignore
|
||||||
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
|
const app = c.get("app");
|
||||||
|
const authCtx = c.get("auth");
|
||||||
|
if (!authCtx) {
|
||||||
|
throw new Error("auth ctx not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// in tests, app is not defined
|
||||||
|
if (!authCtx.registered || !app) {
|
||||||
|
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||||
|
if (app?.module.auth.enabled) {
|
||||||
|
throw new Error(msg);
|
||||||
|
} else {
|
||||||
|
console.warn(msg);
|
||||||
|
}
|
||||||
|
} else if (!authCtx.skip) {
|
||||||
|
const guard = app.modules.ctx().guard;
|
||||||
|
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||||
|
|
||||||
|
if (options?.onGranted || options?.onDenied) {
|
||||||
|
let returned: undefined | void | Response;
|
||||||
|
if (permissions.every((p) => guard.granted(p, c))) {
|
||||||
|
returned = await options?.onGranted?.(c);
|
||||||
|
} else {
|
||||||
|
returned = await options?.onDenied?.(c);
|
||||||
|
}
|
||||||
|
if (returned instanceof Response) {
|
||||||
|
return returned;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { downloadTemplate } from "@bluwy/giget-core";
|
||||||
|
import * as $p from "@clack/prompts";
|
||||||
|
import type { CliCommand } from "cli/types";
|
||||||
|
import { typewriter, wait } from "cli/utils/cli";
|
||||||
|
import { execAsync, getVersion } from "cli/utils/sys";
|
||||||
|
import { Option } from "commander";
|
||||||
|
import { colorizeConsole } from "core";
|
||||||
|
import color from "picocolors";
|
||||||
|
import { overridePackageJson, updateBkndPackages } from "./npm";
|
||||||
|
import { type Template, templates } from "./templates";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
types: {
|
||||||
|
runtime: "Runtime",
|
||||||
|
framework: "Framework"
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
node: "Node.js",
|
||||||
|
bun: "Bun",
|
||||||
|
cloudflare: "Cloudflare"
|
||||||
|
},
|
||||||
|
framework: {
|
||||||
|
nextjs: "Next.js",
|
||||||
|
remix: "Remix",
|
||||||
|
astro: "Astro"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const create: CliCommand = (program) => {
|
||||||
|
program
|
||||||
|
.command("create")
|
||||||
|
.addOption(new Option("-i, --integration <integration>", "integration to use"))
|
||||||
|
.addOption(new Option("-t, --template <template>", "template to use"))
|
||||||
|
.addOption(new Option("-d --dir <directory>", "directory to create in"))
|
||||||
|
.description("create a new project")
|
||||||
|
.action(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorOutro() {
|
||||||
|
$p.outro(color.red("Failed to create project."));
|
||||||
|
console.log(
|
||||||
|
color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") +
|
||||||
|
color.cyan("https://github.com/bknd-io/bknd/issues")
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function action(options: { template?: string; dir?: string; integration?: string }) {
|
||||||
|
console.log("");
|
||||||
|
colorizeConsole(console);
|
||||||
|
|
||||||
|
const downloadOpts = {
|
||||||
|
dir: options.dir || "./",
|
||||||
|
clean: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const version = await getVersion();
|
||||||
|
$p.intro(
|
||||||
|
`👋 Welcome to the ${color.bold(color.cyan("bknd"))} create wizard ${color.bold(`v${version}`)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await $p.stream.message(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter("Thanks for choosing to create a new project with bknd!", color.dim);
|
||||||
|
await wait();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!options.dir) {
|
||||||
|
const dir = await $p.text({
|
||||||
|
message: "Where to create your project?",
|
||||||
|
placeholder: downloadOpts.dir,
|
||||||
|
initialValue: downloadOpts.dir
|
||||||
|
});
|
||||||
|
if ($p.isCancel(dir)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadOpts.dir = dir || "./";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(downloadOpts.dir)) {
|
||||||
|
const clean = await $p.confirm({
|
||||||
|
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
|
||||||
|
initialValue: false
|
||||||
|
});
|
||||||
|
if ($p.isCancel(clean)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadOpts.clean = clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = downloadOpts.dir.includes("/")
|
||||||
|
? downloadOpts.dir.split("/").pop()
|
||||||
|
: downloadOpts.dir.replace(/[./]/g, "");
|
||||||
|
|
||||||
|
if (!name || name.length === 0) name = "bknd";
|
||||||
|
|
||||||
|
let template: Template | undefined;
|
||||||
|
if (options.template) {
|
||||||
|
template = templates.find((t) => t.key === options.template) as Template;
|
||||||
|
if (!template) {
|
||||||
|
$p.log.error(`Template ${color.cyan(options.template)} not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let integration: string | undefined = options.integration;
|
||||||
|
if (!integration) {
|
||||||
|
await $p.stream.info(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter("Ready? ", color.bold, 1.5);
|
||||||
|
await wait(2);
|
||||||
|
yield* typewriter("Let's find the perfect template for you.", color.dim);
|
||||||
|
await wait(2);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const type = await $p.select({
|
||||||
|
message: "Pick an integration type",
|
||||||
|
options: Object.entries(config.types).map(([value, name]) => ({
|
||||||
|
value,
|
||||||
|
label: name,
|
||||||
|
hint: Object.values(config[value]).join(", ")
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($p.isCancel(type)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _integration = await $p.select({
|
||||||
|
message: `Which ${color.cyan(config.types[type])} do you want to continue with?`,
|
||||||
|
options: Object.entries(config[type]).map(([value, name]) => ({
|
||||||
|
value,
|
||||||
|
label: name
|
||||||
|
})) as any
|
||||||
|
});
|
||||||
|
if ($p.isCancel(_integration)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
integration = String(_integration);
|
||||||
|
}
|
||||||
|
if (!integration) {
|
||||||
|
$p.log.error("No integration selected");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log("integration", { type, integration });
|
||||||
|
|
||||||
|
const choices = templates.filter((t) => t.integration === integration);
|
||||||
|
if (choices.length === 0) {
|
||||||
|
$p.log.error(`No templates found for "${color.cyan(String(integration))}"`);
|
||||||
|
process.exit(1);
|
||||||
|
} else if (choices.length > 1) {
|
||||||
|
const selected_template = await $p.select({
|
||||||
|
message: "Pick a template",
|
||||||
|
options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description }))
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($p.isCancel(selected_template)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
template = choices.find((t) => t.key === selected_template) as Template;
|
||||||
|
} else {
|
||||||
|
template = choices[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!template) {
|
||||||
|
$p.log.error("No template selected");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { template, dir: downloadOpts.dir, name };
|
||||||
|
|
||||||
|
{
|
||||||
|
const ref = process.env.BKND_CLI_CREATE_REF ?? `v${version}`;
|
||||||
|
if (process.env.BKND_CLI_CREATE_REF) {
|
||||||
|
$p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix =
|
||||||
|
template.ref === true
|
||||||
|
? `#${ref}`
|
||||||
|
: typeof template.ref === "string"
|
||||||
|
? `#${template.ref}`
|
||||||
|
: "";
|
||||||
|
const url = `${template.path}${prefix}`;
|
||||||
|
|
||||||
|
//console.log("url", url);
|
||||||
|
const s = $p.spinner();
|
||||||
|
await s.start("Downloading template...");
|
||||||
|
try {
|
||||||
|
await downloadTemplate(url, {
|
||||||
|
dir: ctx.dir,
|
||||||
|
force: downloadOpts.clean ? "clean" : true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
s.stop("Failed to download template: " + color.red(e.message), 1);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
s.stop("Failed to download template. Check logs above.", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOutro();
|
||||||
|
}
|
||||||
|
|
||||||
|
s.stop("Template downloaded.");
|
||||||
|
await updateBkndPackages(ctx.dir);
|
||||||
|
|
||||||
|
if (template.preinstall) {
|
||||||
|
await template.preinstall(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update package name
|
||||||
|
await overridePackageJson(
|
||||||
|
(pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
name: ctx.name
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
||||||
|
|
||||||
|
{
|
||||||
|
const install = await $p.confirm({
|
||||||
|
message: "Install dependencies?"
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($p.isCancel(install)) {
|
||||||
|
process.exit(1);
|
||||||
|
} else if (install) {
|
||||||
|
const install_cmd = template.scripts?.install || "npm install";
|
||||||
|
|
||||||
|
const s = $p.spinner();
|
||||||
|
await s.start("Installing dependencies...");
|
||||||
|
try {
|
||||||
|
await execAsync(`cd ${ctx.dir} && ${install_cmd}`, { silent: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
s.stop("Failed to install: " + color.red(e.message), 1);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
s.stop("Failed to install. Check logs above.", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOutro();
|
||||||
|
}
|
||||||
|
|
||||||
|
s.stop("Dependencies installed.");
|
||||||
|
|
||||||
|
if (template!.postinstall) {
|
||||||
|
await template.postinstall(ctx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await $p.stream.warn(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter(
|
||||||
|
color.dim("Remember to run ") +
|
||||||
|
color.cyan("npm install") +
|
||||||
|
color.dim(" after setup")
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.setup) {
|
||||||
|
await template.setup(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await $p.stream.success(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter("That's it! ");
|
||||||
|
await wait(0.5);
|
||||||
|
yield "🎉";
|
||||||
|
await wait();
|
||||||
|
yield "\n\n";
|
||||||
|
yield* typewriter(
|
||||||
|
`Enter your project's directory using ${color.cyan("cd " + ctx.dir)}
|
||||||
|
If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`
|
||||||
|
);
|
||||||
|
await wait(2);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
$p.outro(color.green("Setup complete."));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./create";
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { getVersion as sysGetVersion } from "cli/utils/sys";
|
||||||
|
|
||||||
|
export type TPackageJson = Partial<{
|
||||||
|
name: string;
|
||||||
|
main: string;
|
||||||
|
version: string;
|
||||||
|
scripts: Record<string, string>;
|
||||||
|
dependencies: Record<string, string | undefined>;
|
||||||
|
devDependencies: Record<string, string | undefined>;
|
||||||
|
optionalDependencies: Record<string, string | undefined>;
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function overrideJson<File extends object = object>(
|
||||||
|
file: string,
|
||||||
|
fn: (pkg: File) => Promise<File> | File,
|
||||||
|
opts?: { dir?: string; indent?: number }
|
||||||
|
) {
|
||||||
|
const pkgPath = path.resolve(opts?.dir ?? process.cwd(), file);
|
||||||
|
const pkg = await readFile(pkgPath, "utf-8");
|
||||||
|
const newPkg = await fn(JSON.parse(pkg));
|
||||||
|
await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function overridePackageJson(
|
||||||
|
fn: (pkg: TPackageJson) => Promise<TPackageJson> | TPackageJson,
|
||||||
|
opts?: { dir?: string }
|
||||||
|
) {
|
||||||
|
return await overrideJson("package.json", fn, { dir: opts?.dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPackageInfo(pkg: string, version?: string): Promise<TPackageJson> {
|
||||||
|
const res = await fetch(`https://registry.npmjs.org/${pkg}${version ? `/${version}` : ""}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersion(pkg: string, version: string = "latest") {
|
||||||
|
const info = await getPackageInfo(pkg, version);
|
||||||
|
return info.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _deps = ["dependencies", "devDependencies", "optionalDependencies"] as const;
|
||||||
|
export async function replacePackageJsonVersions(
|
||||||
|
fn: (pkg: string, version: string) => Promise<string | undefined> | string | undefined,
|
||||||
|
opts?: { include?: (keyof typeof _deps)[]; dir?: string }
|
||||||
|
) {
|
||||||
|
const deps = (opts?.include ?? _deps) as string[];
|
||||||
|
await overridePackageJson(
|
||||||
|
async (json) => {
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (dep in json) {
|
||||||
|
for (const [pkg, version] of Object.entries(json[dep])) {
|
||||||
|
const newVersion = await fn(pkg, version as string);
|
||||||
|
if (newVersion) {
|
||||||
|
json[dep][pkg] = newVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
{ dir: opts?.dir }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
||||||
|
const versions = {
|
||||||
|
bknd: "^" + (await sysGetVersion()),
|
||||||
|
...(map ?? {})
|
||||||
|
};
|
||||||
|
await replacePackageJsonVersions(
|
||||||
|
async (pkg) => {
|
||||||
|
if (pkg in versions) {
|
||||||
|
return versions[pkg];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
{ dir }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as $p from "@clack/prompts";
|
||||||
|
import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
|
||||||
|
import { typewriter, wait } from "cli/utils/cli";
|
||||||
|
import { uuid } from "core/utils";
|
||||||
|
import c from "picocolors";
|
||||||
|
import type { Template, TemplateSetupCtx } from ".";
|
||||||
|
|
||||||
|
const WRANGLER_FILE = "wrangler.json";
|
||||||
|
|
||||||
|
export const cloudflare = {
|
||||||
|
key: "cloudflare",
|
||||||
|
title: "Cloudflare Basic",
|
||||||
|
integration: "cloudflare",
|
||||||
|
description: "A basic bknd Cloudflare worker",
|
||||||
|
path: "gh:bknd-io/bknd/examples/cloudflare-worker",
|
||||||
|
ref: true,
|
||||||
|
setup: async (ctx) => {
|
||||||
|
// overwrite assets directory & name
|
||||||
|
await overrideJson(
|
||||||
|
WRANGLER_FILE,
|
||||||
|
(json) => ({
|
||||||
|
...json,
|
||||||
|
name: ctx.name,
|
||||||
|
assets: {
|
||||||
|
directory: "node_modules/bknd/dist/static"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
const db = await $p.select({
|
||||||
|
message: "What database do you want to use?",
|
||||||
|
options: [
|
||||||
|
{ label: "Cloudflare D1", value: "d1" },
|
||||||
|
{ label: "LibSQL", value: "libsql" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if ($p.isCancel(db)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (db) {
|
||||||
|
case "d1":
|
||||||
|
await createD1(ctx);
|
||||||
|
break;
|
||||||
|
case "libsql":
|
||||||
|
await createLibsql(ctx);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid database");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const message = (e as any).message || "An error occurred";
|
||||||
|
$p.log.warn(
|
||||||
|
"Couldn't add database. You can add it manually later. Error: " + c.red(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const satisfies Template;
|
||||||
|
|
||||||
|
async function createD1(ctx: TemplateSetupCtx) {
|
||||||
|
const name = await $p.text({
|
||||||
|
message: "Enter database name",
|
||||||
|
initialValue: "data",
|
||||||
|
placeholder: "data",
|
||||||
|
validate: (v) => {
|
||||||
|
if (!v) {
|
||||||
|
return "Invalid name";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ($p.isCancel(name)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await overrideJson(
|
||||||
|
WRANGLER_FILE,
|
||||||
|
(json) => ({
|
||||||
|
...json,
|
||||||
|
d1_databases: [
|
||||||
|
{
|
||||||
|
binding: "DB",
|
||||||
|
database_name: name,
|
||||||
|
database_id: uuid()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
await $p.stream.info(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter(`Database added to ${c.cyan("wrangler.json")}`);
|
||||||
|
await wait();
|
||||||
|
yield* typewriter(
|
||||||
|
`\nNote that if you deploy, you have to create a real database using ${c.cyan("npx wrangler d1 create <name>")} and update your wrangler configuration.`,
|
||||||
|
c.dim
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLibsql(ctx: TemplateSetupCtx) {
|
||||||
|
await overrideJson(
|
||||||
|
WRANGLER_FILE,
|
||||||
|
(json) => ({
|
||||||
|
...json,
|
||||||
|
vars: {
|
||||||
|
DB_URL: "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
await overridePackageJson(
|
||||||
|
(pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
scripts: {
|
||||||
|
...pkg.scripts,
|
||||||
|
db: "turso dev",
|
||||||
|
dev: "npm run db && wrangler dev"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
|
||||||
|
await $p.stream.info(
|
||||||
|
(async function* () {
|
||||||
|
yield* typewriter("Database set to LibSQL");
|
||||||
|
await wait();
|
||||||
|
yield* typewriter(
|
||||||
|
`\nYou can now run ${c.cyan("npm run db")} to start the database and ${c.cyan("npm run dev")} to start the worker.`,
|
||||||
|
c.dim
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
yield* typewriter(
|
||||||
|
`\nAlso make sure you have Turso's CLI installed. Check their docs on how to install at ${c.cyan("https://docs.turso.tech/cli/introduction")}`,
|
||||||
|
c.dim
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cloudflare } from "./cloudflare";
|
||||||
|
import { nextjs } from "./nextjs";
|
||||||
|
import { remix } from "./remix";
|
||||||
|
|
||||||
|
export type TemplateSetupCtx = {
|
||||||
|
template: Template;
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
|
||||||
|
|
||||||
|
type TemplateScripts = "install" | "dev" | "build" | "start";
|
||||||
|
export type Template = {
|
||||||
|
/**
|
||||||
|
* unique key for the template
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* the integration this template is for
|
||||||
|
*/
|
||||||
|
integration: Integration;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
path: string;
|
||||||
|
/**
|
||||||
|
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
|
||||||
|
*/
|
||||||
|
ref?: true | string;
|
||||||
|
scripts?: Partial<Record<TemplateScripts, string>>;
|
||||||
|
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
|
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
|
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templates: Template[] = [
|
||||||
|
cloudflare,
|
||||||
|
nextjs,
|
||||||
|
remix,
|
||||||
|
{
|
||||||
|
key: "node",
|
||||||
|
title: "Node.js Basic",
|
||||||
|
integration: "node",
|
||||||
|
description: "A basic bknd Node.js server",
|
||||||
|
path: "gh:bknd-io/bknd/examples/node",
|
||||||
|
ref: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bun",
|
||||||
|
title: "Bun Basic",
|
||||||
|
integration: "bun",
|
||||||
|
description: "A basic bknd Bun server",
|
||||||
|
path: "gh:bknd-io/bknd/examples/bun",
|
||||||
|
ref: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "astro",
|
||||||
|
title: "Astro Basic",
|
||||||
|
integration: "astro",
|
||||||
|
description: "A basic bknd Astro starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/astro",
|
||||||
|
ref: true
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { overridePackageJson } from "cli/commands/create/npm";
|
||||||
|
import type { Template } from ".";
|
||||||
|
|
||||||
|
// @todo: add `concurrently`?
|
||||||
|
export const nextjs = {
|
||||||
|
key: "nextjs",
|
||||||
|
title: "Next.js Basic",
|
||||||
|
integration: "nextjs",
|
||||||
|
description: "A basic bknd Next.js starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/nextjs",
|
||||||
|
scripts: {
|
||||||
|
install: "npm install --force"
|
||||||
|
},
|
||||||
|
ref: true,
|
||||||
|
preinstall: async (ctx) => {
|
||||||
|
// locally it's required to overwrite react, here it is not
|
||||||
|
await overridePackageJson(
|
||||||
|
(pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
dependencies: {
|
||||||
|
...pkg.dependencies,
|
||||||
|
react: undefined,
|
||||||
|
"react-dom": undefined
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} as const satisfies Template;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { overridePackageJson } from "cli/commands/create/npm";
|
||||||
|
import type { Template } from ".";
|
||||||
|
|
||||||
|
export const remix = {
|
||||||
|
key: "remix",
|
||||||
|
title: "Remix Basic",
|
||||||
|
integration: "remix",
|
||||||
|
description: "A basic bknd Remix starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/remix",
|
||||||
|
ref: true,
|
||||||
|
preinstall: async (ctx) => {
|
||||||
|
// locally it's required to overwrite react
|
||||||
|
await overridePackageJson(
|
||||||
|
(pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
dependencies: {
|
||||||
|
...pkg.dependencies,
|
||||||
|
react: "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} as const satisfies Template;
|
||||||
@@ -1,20 +1,44 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
|
import { createApp } from "App";
|
||||||
|
import { getConnectionCredentialsFromEnv } from "cli/commands/run/platform";
|
||||||
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
|
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
|
||||||
|
import { Argument } from "commander";
|
||||||
|
import { showRoutes } from "hono/dev";
|
||||||
import type { CliCommand } from "../types";
|
import type { CliCommand } from "../types";
|
||||||
|
|
||||||
export const debug: CliCommand = (program) => {
|
export const debug: CliCommand = (program) => {
|
||||||
program
|
program
|
||||||
.command("debug")
|
.command("debug")
|
||||||
.description("debug path resolution")
|
.description("debug bknd")
|
||||||
.action(() => {
|
.addArgument(new Argument("<subject>", "subject to debug").choices(Object.keys(subjects)))
|
||||||
console.log("paths", {
|
.action(action);
|
||||||
rootpath: getRootPath(),
|
|
||||||
distPath: getDistPath(),
|
|
||||||
relativeDistPath: getRelativeDistPath(),
|
|
||||||
cwd: process.cwd(),
|
|
||||||
dir: path.dirname(url.fileURLToPath(import.meta.url)),
|
|
||||||
resolvedPkg: path.resolve(getRootPath(), "package.json")
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subjects = {
|
||||||
|
paths: async () => {
|
||||||
|
console.log("[PATHS]", {
|
||||||
|
rootpath: getRootPath(),
|
||||||
|
distPath: getDistPath(),
|
||||||
|
relativeDistPath: getRelativeDistPath(),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
dir: path.dirname(url.fileURLToPath(import.meta.url)),
|
||||||
|
resolvedPkg: path.resolve(getRootPath(), "package.json")
|
||||||
|
});
|
||||||
|
},
|
||||||
|
routes: async () => {
|
||||||
|
console.log("[APP ROUTES]");
|
||||||
|
const credentials = getConnectionCredentialsFromEnv();
|
||||||
|
const app = createApp({ connection: credentials });
|
||||||
|
await app.build();
|
||||||
|
showRoutes(app.server);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function action(subject: string) {
|
||||||
|
if (subject in subjects) {
|
||||||
|
await subjects[subject]();
|
||||||
|
} else {
|
||||||
|
console.error("Invalid subject: ", subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { schema } from "./schema";
|
|||||||
export { run } from "./run";
|
export { run } from "./run";
|
||||||
export { debug } from "./debug";
|
export { debug } from "./debug";
|
||||||
export { user } from "./user";
|
export { user } from "./user";
|
||||||
|
export { create } from "./create";
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ServeStaticOptions } from "@hono/node-server/serve-static";
|
import type { Config } from "@libsql/client/node";
|
||||||
import { type Config, createClient } from "@libsql/client/node";
|
import { config } from "core";
|
||||||
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
|
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys";
|
import open from "open";
|
||||||
|
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
||||||
|
|
||||||
export const PLATFORMS = ["node", "bun"] as const;
|
export const PLATFORMS = ["node", "bun"] as const;
|
||||||
export type Platform = (typeof PLATFORMS)[number];
|
export type Platform = (typeof PLATFORMS)[number];
|
||||||
@@ -28,12 +27,13 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function attachServeStatic(app: any, platform: Platform) {
|
export async function attachServeStatic(app: any, platform: Platform) {
|
||||||
app.module.server.client.get("/*", await serveStatic(platform));
|
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||||
const port = options.port;
|
const port = options.port;
|
||||||
console.log("running on", server, port);
|
console.log(`Using ${server} serve`);
|
||||||
|
|
||||||
switch (server) {
|
switch (server) {
|
||||||
case "node": {
|
case "node": {
|
||||||
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
|
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
|
||||||
@@ -53,27 +53,9 @@ export async function startServer(server: Platform, app: any, options: { port: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Server listening on", "http://localhost:" + port);
|
const url = `http://localhost:${port}`;
|
||||||
}
|
console.info("Server listening on", url);
|
||||||
|
await open(url);
|
||||||
export async function getHtml() {
|
|
||||||
return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getConnection(connectionOrConfig?: Connection | Config): Connection {
|
|
||||||
if (connectionOrConfig) {
|
|
||||||
if (connectionOrConfig instanceof Connection) {
|
|
||||||
return connectionOrConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("url" in connectionOrConfig) {
|
|
||||||
return new LibsqlConnection(createClient(connectionOrConfig));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Using in-memory database");
|
|
||||||
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
|
||||||
//return new SqliteLocalConnection(new Database(":memory:"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConfigPath(filePath?: string) {
|
export async function getConfigPath(filePath?: string) {
|
||||||
@@ -94,3 +76,9 @@ export async function getConfigPath(filePath?: string) {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConnectionCredentialsFromEnv() {
|
||||||
|
const dbUrl = process.env.DB_URL;
|
||||||
|
const dbToken = process.env.DB_TOKEN;
|
||||||
|
return dbUrl ? { url: dbUrl, authToken: dbToken } : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import type { Config } from "@libsql/client/node";
|
import type { Config } from "@libsql/client/node";
|
||||||
import { App } from "App";
|
import { App, type CreateAppConfig } from "App";
|
||||||
import type { BkndConfig } from "adapter";
|
import { StorageLocalAdapter } from "adapter/node";
|
||||||
import type { CliCommand } from "cli/types";
|
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||||
import { Option } from "commander";
|
import { Option } from "commander";
|
||||||
import type { Connection } from "data";
|
import { colorizeConsole, config } from "core";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { registries } from "modules/registries";
|
||||||
|
import c from "picocolors";
|
||||||
import {
|
import {
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
type Platform,
|
type Platform,
|
||||||
attachServeStatic,
|
attachServeStatic,
|
||||||
getConfigPath,
|
getConfigPath,
|
||||||
getConnection,
|
getConnectionCredentialsFromEnv,
|
||||||
getHtml,
|
|
||||||
startServer
|
startServer
|
||||||
} from "./platform";
|
} from "./platform";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
const isBun = typeof Bun !== "undefined";
|
const isBun = typeof Bun !== "undefined";
|
||||||
|
|
||||||
export const run: CliCommand = (program) => {
|
export const run: CliCommand = (program) => {
|
||||||
@@ -22,9 +25,16 @@ export const run: CliCommand = (program) => {
|
|||||||
.addOption(
|
.addOption(
|
||||||
new Option("-p, --port <port>", "port to run on")
|
new Option("-p, --port <port>", "port to run on")
|
||||||
.env("PORT")
|
.env("PORT")
|
||||||
.default(1337)
|
.default(config.server.default_port)
|
||||||
.argParser((v) => Number.parseInt(v))
|
.argParser((v) => Number.parseInt(v))
|
||||||
)
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option("-m, --memory", "use in-memory database").conflicts([
|
||||||
|
"config",
|
||||||
|
"db-url",
|
||||||
|
"db-token"
|
||||||
|
])
|
||||||
|
)
|
||||||
.addOption(new Option("-c, --config <config>", "config file"))
|
.addOption(new Option("-c, --config <config>", "config file"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
|
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
|
||||||
@@ -40,18 +50,24 @@ export const run: CliCommand = (program) => {
|
|||||||
.action(action);
|
.action(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// automatically register local adapter
|
||||||
|
const local = StorageLocalAdapter.prototype.getName();
|
||||||
|
if (!registries.media.has(local)) {
|
||||||
|
registries.media.register(local, StorageLocalAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
type MakeAppConfig = {
|
type MakeAppConfig = {
|
||||||
connection: Connection;
|
connection?: CreateAppConfig["connection"];
|
||||||
server?: { platform?: Platform };
|
server?: { platform?: Platform };
|
||||||
setAdminHtml?: boolean;
|
setAdminHtml?: boolean;
|
||||||
onBuilt?: (app: App) => Promise<void>;
|
onBuilt?: (app: App) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function makeApp(config: MakeAppConfig) {
|
async function makeApp(config: MakeAppConfig) {
|
||||||
const app = new App(config.connection);
|
const app = App.create({ connection: config.connection });
|
||||||
|
|
||||||
app.emgr.on(
|
app.emgr.onEvent(
|
||||||
"app-built",
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
await attachServeStatic(app, config.server?.platform ?? "node");
|
await attachServeStatic(app, config.server?.platform ?? "node");
|
||||||
app.registerAdminController();
|
app.registerAdminController();
|
||||||
@@ -67,46 +83,66 @@ async function makeApp(config: MakeAppConfig) {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
|
||||||
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
||||||
const app = App.create(appConfig);
|
const app = App.create(appConfig);
|
||||||
|
|
||||||
app.emgr.on(
|
app.emgr.onEvent(
|
||||||
"app-built",
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
await attachServeStatic(app, platform ?? "node");
|
await attachServeStatic(app, platform ?? "node");
|
||||||
app.registerAdminController();
|
app.registerAdminController();
|
||||||
|
|
||||||
if (config.onBuilt) {
|
await config.onBuilt?.(app);
|
||||||
await config.onBuilt(app);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
|
|
||||||
await app.build();
|
await config.beforeBuild?.(app);
|
||||||
|
await app.build(config.buildConfig);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function action(options: {
|
async function action(options: {
|
||||||
port: number;
|
port: number;
|
||||||
|
memory?: boolean;
|
||||||
config?: string;
|
config?: string;
|
||||||
dbUrl?: string;
|
dbUrl?: string;
|
||||||
dbToken?: string;
|
dbToken?: string;
|
||||||
server: Platform;
|
server: Platform;
|
||||||
}) {
|
}) {
|
||||||
|
colorizeConsole(console);
|
||||||
const configFilePath = await getConfigPath(options.config);
|
const configFilePath = await getConfigPath(options.config);
|
||||||
|
|
||||||
let app: App;
|
let app: App | undefined = undefined;
|
||||||
if (options.dbUrl || !configFilePath) {
|
if (options.dbUrl) {
|
||||||
const connection = getConnection(
|
console.info("Using connection from", c.cyan("--db-url"));
|
||||||
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined
|
const connection = options.dbUrl
|
||||||
);
|
? { url: options.dbUrl, authToken: options.dbToken }
|
||||||
|
: undefined;
|
||||||
app = await makeApp({ connection, server: { platform: options.server } });
|
app = await makeApp({ connection, server: { platform: options.server } });
|
||||||
} else {
|
} else if (configFilePath) {
|
||||||
console.log("Using config from:", configFilePath);
|
console.info("Using config from", c.cyan(configFilePath));
|
||||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||||
app = await makeConfigApp(config, options.server);
|
app = await makeConfigApp(config, options.server);
|
||||||
|
} else if (options.memory) {
|
||||||
|
console.info("Using", c.cyan("in-memory"), "connection");
|
||||||
|
app = await makeApp({ server: { platform: options.server } });
|
||||||
|
} else {
|
||||||
|
const credentials = getConnectionCredentialsFromEnv();
|
||||||
|
if (credentials) {
|
||||||
|
console.info("Using connection from env", c.cyan(credentials.url));
|
||||||
|
app = await makeConfigApp({ app: { connection: credentials } }, options.server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
const connection = { url: "file:data.db" } as Config;
|
||||||
|
console.info("Using connection", c.cyan(connection.url));
|
||||||
|
app = await makeApp({
|
||||||
|
connection,
|
||||||
|
server: { platform: options.server }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await startServer(options.server, app, { port: options.port });
|
await startServer(options.server, app, { port: options.port });
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { password as $password, text as $text } from "@clack/prompts";
|
import { password as $password, text as $text } from "@clack/prompts";
|
||||||
|
import type { App } from "App";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import type { App, BkndConfig } from "bknd";
|
|
||||||
import { makeConfigApp } from "cli/commands/run";
|
import { makeConfigApp } from "cli/commands/run";
|
||||||
import { getConfigPath } from "cli/commands/run/platform";
|
import { getConfigPath } from "cli/commands/run/platform";
|
||||||
import type { CliCommand } from "cli/types";
|
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||||
import { Argument } from "commander";
|
import { Argument } from "commander";
|
||||||
|
|
||||||
export const user: CliCommand = (program) => {
|
export const user: CliCommand = (program) => {
|
||||||
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||||
const app = await makeConfigApp(config, options.server);
|
const app = await makeConfigApp(config, options.server);
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function create(app: App, options: any) {
|
async function create(app: App, options: any) {
|
||||||
const config = app.module.auth.toJSON(true);
|
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
const users_entity = config.entity_name;
|
|
||||||
|
if (!strategy) {
|
||||||
|
throw new Error("Password strategy not configured");
|
||||||
|
}
|
||||||
|
|
||||||
const email = await $text({
|
const email = await $text({
|
||||||
message: "Enter email",
|
message: "Enter email",
|
||||||
@@ -65,16 +67,11 @@ async function create(app: App, options: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mutator = app.modules.ctx().em.mutator(users_entity);
|
const created = await app.createUser({
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
|
||||||
const res = await mutator.insertOne({
|
|
||||||
email,
|
email,
|
||||||
strategy: "password",
|
password: await strategy.hash(password as string)
|
||||||
strategy_value: await strategy.hash(password as string)
|
})
|
||||||
});
|
console.log("Created:", created);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
|
||||||
|
|
||||||
console.log("Created:", res.data);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error", e);
|
console.error("Error", e);
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,7 @@ async function create(app: App, options: any) {
|
|||||||
async function update(app: App, options: any) {
|
async function update(app: App, options: any) {
|
||||||
const config = app.module.auth.toJSON(true);
|
const config = app.module.auth.toJSON(true);
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
const users_entity = config.entity_name;
|
const users_entity = config.entity_name as "users";
|
||||||
const em = app.modules.ctx().em;
|
const em = app.modules.ctx().em;
|
||||||
|
|
||||||
const email = (await $text({
|
const email = (await $text({
|
||||||
@@ -141,4 +138,4 @@ async function update(app: App, options: any) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error", e);
|
console.error("Error", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import color from "picocolors";
|
||||||
import * as commands from "./commands";
|
import * as commands from "./commands";
|
||||||
import { getVersion } from "./utils/sys";
|
import { getVersion } from "./utils/sys";
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
|
const version = await getVersion();
|
||||||
program
|
program
|
||||||
.name("bknd")
|
.name("bknd")
|
||||||
.description("bknd cli")
|
.description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
|
||||||
.version(await getVersion());
|
.version(version);
|
||||||
|
|
||||||
// register commands
|
// register commands
|
||||||
for (const command of Object.values(commands)) {
|
for (const command of Object.values(commands)) {
|
||||||
|
|||||||
Vendored
+11
@@ -1,3 +1,14 @@
|
|||||||
|
import type { CreateAppConfig } from "App";
|
||||||
|
import type { FrameworkBkndConfig } from "adapter";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
export type CliCommand = (program: Command) => void;
|
export type CliCommand = (program: Command) => void;
|
||||||
|
|
||||||
|
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
|
||||||
|
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||||
|
setAdminHtml?: boolean;
|
||||||
|
server?: {
|
||||||
|
port?: number;
|
||||||
|
platform?: "node" | "bun";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user