mirror of
https://github.com/bknd-io/bknd.git
synced 2026-10-25 11:48:31 +00:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c46eef885 | ||
|
|
ea8b69ea00 | ||
|
|
97aaeaa763 | ||
|
|
5f493211e4 | ||
|
|
772e9b4748 | ||
|
|
eb1496ad0a | ||
|
|
4709fec45c | ||
|
|
da80e91074 | ||
|
|
3ad9fd01e1 | ||
|
|
89d7e4ea2b | ||
|
|
6e3b7515d5 | ||
|
|
62d3a9c4f4 | ||
|
|
919d9f15d1 | ||
|
|
e68d29760a | ||
|
|
eba2f8a535 | ||
|
|
6cfdb77ba0 | ||
|
|
8a90b9356b | ||
|
|
b070fb591e | ||
|
|
6489d08434 | ||
|
|
8c596781ff | ||
|
|
b2315ed012 | ||
|
|
9e5c77db79 | ||
|
|
5892b898bf | ||
|
|
fc83a593a0 | ||
|
|
8d37f9df3f | ||
|
|
81bf41ce34 | ||
|
|
d9ab583a16 | ||
|
|
e68df5e606 | ||
|
|
6304ef81f6 | ||
|
|
43cbdb3010 | ||
|
|
6c7e82781d | ||
|
|
a8e5c45a22 | ||
|
|
ee28c2ef0c | ||
|
|
9219bf3b3c | ||
|
|
b3c9f258de | ||
|
|
e437ce7120 | ||
|
|
2417833ed7 | ||
|
|
ab1eddcb6a | ||
|
|
237b42bb9c | ||
|
|
d206c38d87 | ||
|
|
d160af5cec | ||
|
|
d03cd7f7bc | ||
|
|
4afba215b9 | ||
|
|
dc8aca6a97 | ||
|
|
4121597fa2 | ||
|
|
9628720f87 | ||
|
|
ff216ec4e5 | ||
|
|
06f9c3ee15 | ||
|
|
7751ee5db8 | ||
|
|
feb3911d46 | ||
|
|
ab545d3ca2 | ||
|
|
06c3a6f89b | ||
|
|
898a825f6e | ||
|
|
a5ddf6aa93 | ||
|
|
5aa286298c | ||
|
|
55cb2bacef | ||
|
|
e6ea3f64d7 | ||
|
|
a5f5ea2084 | ||
|
|
6fb8e9a0e7 | ||
|
|
abc6de6ad3 | ||
|
|
71b9a0f546 | ||
|
|
038ec0d672 | ||
|
|
7832328a50 | ||
|
|
e5bfc602e1 | ||
|
|
0318e595f2 | ||
|
|
80b0716a4a | ||
|
|
3894271b17 | ||
|
|
8bf8b22e57 | ||
|
|
367d5878df | ||
|
|
fb429e57e2 | ||
|
|
9008f9c6a3 | ||
|
|
6321a9935a | ||
|
|
eb22289460 | ||
|
|
c3198394de | ||
|
|
51237f5e1a | ||
|
|
01483b912f | ||
|
|
6288faef33 | ||
|
|
384e337bbd | ||
|
|
cc1e1976f7 | ||
|
|
224d98879a | ||
|
|
d1522c97ea | ||
|
|
8805f017a1 | ||
|
|
0ef06375c0 | ||
|
|
3d5c2cd3a8 | ||
|
|
fc60dfd042 | ||
|
|
c8ee30b3da | ||
|
|
b85c3a8a9b | ||
|
|
e20c2ef92c | ||
|
|
5873baad3c | ||
|
|
aca162f9c8 | ||
|
|
9f0997c28a | ||
|
|
1c114d9533 | ||
|
|
68063986d1 | ||
|
|
53f8609e9d | ||
|
|
833bfa5049 | ||
|
|
defcdfef0b | ||
|
|
348ac7f8b0 | ||
|
|
16b1cde8ea | ||
|
|
436b195242 | ||
|
|
ccd504440e | ||
|
|
023f5391e4 | ||
|
|
11e3488917 | ||
|
|
9685c5d4e1 | ||
|
|
9b008be512 | ||
|
|
5352331464 | ||
|
|
d9bce049ef | ||
|
|
0e4f25cf11 | ||
|
|
4a5bdade8d | ||
|
|
047bb80a97 | ||
|
|
d826cb23a3 | ||
|
|
23a9e7c278 | ||
|
|
a9fe43130e | ||
|
|
3b7ec06703 | ||
|
|
55a124b519 | ||
|
|
dc3fe330d7 | ||
|
|
d541167fb3 | ||
|
|
7e8b357bbb | ||
|
|
521d8bc6ff | ||
|
|
19f1941544 | ||
|
|
f9373bd0e6 | ||
|
|
5384738b67 | ||
|
|
d82e28e6b1 | ||
|
|
c38bc95b1d | ||
|
|
6651fce099 | ||
|
|
91a1fc8a0c | ||
|
|
d00e5fe68c | ||
|
|
6faf0d421f | ||
|
|
19e51fae63 | ||
|
|
7e01cc6101 | ||
|
|
103cbd11df | ||
|
|
03d3b8e407 | ||
|
|
a97a79f11e | ||
|
|
90b4de7093 | ||
|
|
81f3914e7f | ||
|
|
5163e2142e | ||
|
|
15ed075de3 | ||
|
|
09b9a5e857 | ||
|
|
4f7925dfd8 | ||
|
|
21e970cd8c | ||
|
|
40b70e7a20 | ||
|
|
9958dd7308 | ||
|
|
fef77eea61 | ||
|
|
ed41887d74 | ||
|
|
8ba4793160 | ||
|
|
65e17db2a2 | ||
|
|
e21b5e5b55 | ||
|
|
2e6b95e484 | ||
|
|
4e31f48917 | ||
|
|
5c3d3d763e | ||
|
|
407d103a5f | ||
|
|
2fd8b9f9a0 | ||
|
|
e81636ea95 | ||
|
|
37c36e5d07 | ||
|
|
ac891a4b61 | ||
|
|
412eab6653 | ||
|
|
2fb07698d5 | ||
|
|
64e400c4f1 | ||
|
|
86d6e891a8 | ||
|
|
7b86611f35 | ||
|
|
9e316a92f6 | ||
|
|
12ef72d595 | ||
|
|
75c29ae5e2 | ||
|
|
f0551f50d1 | ||
|
|
03aac3437b | ||
|
|
f07f3d1007 | ||
|
|
c833b2b539 | ||
|
|
6070394cca |
No files matched your search
+267
@@ -0,0 +1,267 @@
|
|||||||
|
# Contributing to bknd
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to bknd. This guide will help you get started, understand the codebase, and submit contributions that align with the project's direction.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Before You Start](#before-you-start)
|
||||||
|
- [Important Resources](#important-resources)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Understanding the Codebase](#understanding-the-codebase)
|
||||||
|
- [Running Tests](#running-tests)
|
||||||
|
- [Code Style](#code-style)
|
||||||
|
- [Submitting Changes](#submitting-changes)
|
||||||
|
- [AI-Generated Code](#ai-generated-code)
|
||||||
|
- [Contributing to Documentation](#contributing-to-documentation)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Getting Help](#getting-help)
|
||||||
|
- [Contributors](#contributors)
|
||||||
|
- [Maintainers](#maintainers)
|
||||||
|
|
||||||
|
## Before You Start
|
||||||
|
|
||||||
|
**Open a GitHub Issue before writing code.** This is the preferred way to propose any change. It lets maintainers and the community align on the approach before time is spent on implementation. If you have discussed a contribution in the [Discord server](https://discord.com/invite/952SFk8Tb8) instead, include a link to the relevant Discord thread in your pull request. Pull requests submitted without a corresponding issue or Discord discussion may be closed.
|
||||||
|
|
||||||
|
**Unsolicited architectural changes will be closed.** The internal architecture of bknd is intentional. Refactors, restructuring, or changes to core patterns must be discussed and approved before any code is written.
|
||||||
|
|
||||||
|
Contributions that are generally welcome (after opening an issue):
|
||||||
|
|
||||||
|
- Bug fixes
|
||||||
|
- New adapters, strategies, or storage backends
|
||||||
|
- Documentation improvements
|
||||||
|
- New examples
|
||||||
|
- Test coverage improvements
|
||||||
|
|
||||||
|
**A note on versioning**: bknd is pre-1.0 and under active development. Full backward compatibility is not guaranteed before v1.0.0. Contributors should be aware that APIs and internal interfaces may change between releases.
|
||||||
|
|
||||||
|
## Important Resources
|
||||||
|
|
||||||
|
- **Documentation**: https://docs.bknd.io
|
||||||
|
- **Issue Tracker**: https://github.com/bknd-io/bknd/issues
|
||||||
|
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
||||||
|
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22.13 or higher
|
||||||
|
- Bun 1.3.3
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository and clone your fork.
|
||||||
|
|
||||||
|
2. Install dependencies from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Navigate to the main app package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy the example environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `.env.example` includes a file-based SQLite database (`file:.db/dev.db`). You can change `VITE_DB_URL` to `:memory:` for an in-memory database instead.
|
||||||
|
|
||||||
|
5. Start the dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev server runs on `http://localhost:28623` using Vite with Hono.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This is a Bun monorepo using workspaces. The vast majority of the code lives in `app/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
bknd/
|
||||||
|
app/ # Main "bknd" npm package (this is where most work happens)
|
||||||
|
src/
|
||||||
|
adapter/ # Runtime/framework adapters (node, bun, cloudflare, nextjs, astro, etc.)
|
||||||
|
auth/ # Authentication module (strategies, sessions, user pool)
|
||||||
|
cli/ # CLI commands
|
||||||
|
core/ # Shared utilities, event system, drivers, server helpers
|
||||||
|
data/ # Data module (entities, fields, relations, queries, schema)
|
||||||
|
flows/ # Workflow engine and tasks
|
||||||
|
media/ # Media module (storage adapters, uploads)
|
||||||
|
modes/ # Configuration modes (code, hybrid, db)
|
||||||
|
modules/ # Module system, MCP server, permissions framework
|
||||||
|
plugins/ # Built-in plugins (auth, data, cloudflare, dev)
|
||||||
|
ui/ # All frontend code
|
||||||
|
client/ # TypeScript SDK and React hooks (exported as bknd/client)
|
||||||
|
elements/ # React components for auth/media (exported as bknd/elements)
|
||||||
|
(everything else) # Admin UI (exported as bknd/ui)
|
||||||
|
App.ts # Central application orchestrator
|
||||||
|
index.ts # Main package exports
|
||||||
|
__test__/ # Unit tests (mirrors src/ structure)
|
||||||
|
e2e/ # End-to-end tests (Playwright)
|
||||||
|
build.ts # Build script (tsup/esbuild)
|
||||||
|
build.cli.ts # CLI build script
|
||||||
|
packages/ # Small satellite packages
|
||||||
|
cli/ # Standalone CLI package (bknd-cli)
|
||||||
|
plasmic/ # Plasmic integration
|
||||||
|
postgres/ # Postgres helper
|
||||||
|
sqlocal/ # SQLocal helper
|
||||||
|
docs/ # Documentation site (Next.js + Fumadocs, deployed to docs.bknd.io)
|
||||||
|
examples/ # Example projects across runtimes and frameworks
|
||||||
|
docker/ # Docker configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding the Codebase
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
The project uses TypeScript path aliases defined in `app/tsconfig.json`. Imports like `core/utils`, `data/connection`, or `auth/authenticate` resolve to directories under `app/src/`. When reading source code, keep this in mind -- an import like `import { Connection } from "data/connection"` refers to `app/src/data/connection`, not an external package.
|
||||||
|
|
||||||
|
### Module System
|
||||||
|
|
||||||
|
bknd is built around four core modules, each with its own schema, API routes, and permissions:
|
||||||
|
|
||||||
|
- **Data** -- entity definitions, field types, relations, queries (backed by Kysely)
|
||||||
|
- **Auth** -- authentication strategies, sessions, user management
|
||||||
|
- **Media** -- file storage with pluggable adapters (S3, Cloudinary, local filesystem, etc.)
|
||||||
|
- **Flows** -- workflow automation and task execution
|
||||||
|
|
||||||
|
These modules are managed by the `ModuleManager` (in `app/src/modules/`), which handles configuration, building, and lifecycle.
|
||||||
|
|
||||||
|
### Adapter Pattern
|
||||||
|
|
||||||
|
Adapters in `app/src/adapter/` allow bknd to run on different runtimes and frameworks. Each adapter provides the glue between bknd's Hono-based server and a specific environment (Node, Bun, Cloudflare Workers, Next.js, Astro, etc.).
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `beforeBuild`, `onBuilt`, `onServerInit`, and `schema`. They can add entities, register routes, and extend behavior.
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
|
||||||
|
|
||||||
|
- **API** -- the core backend
|
||||||
|
- **UI** -- the admin interface
|
||||||
|
- **Elements** -- standalone React components
|
||||||
|
- **Adapters** -- all runtime/framework adapters
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
All test commands run from the `app/` directory.
|
||||||
|
|
||||||
|
| Command | Runner | What it runs |
|
||||||
|
|---|---|---|
|
||||||
|
| `bun run test` | Bun test | Unit tests (`*.spec.ts` files) |
|
||||||
|
| `bun run test:node` | Vitest | Node-specific tests (`*.vi-test.ts`, `*.vitest.ts`) |
|
||||||
|
| `bun run test:e2e` | Playwright | End-to-end tests (`*.e2e-spec.ts` in `e2e/`) |
|
||||||
|
| `bun run types` | TypeScript | Type checking (no emit) |
|
||||||
|
|
||||||
|
CI runs both Bun and Node tests, with a Postgres 17 service for database integration tests.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
The project uses **Biome** for formatting and linting.
|
||||||
|
|
||||||
|
- 3-space indentation for JavaScript/TypeScript
|
||||||
|
- 100-character line width
|
||||||
|
- Spaces (not tabs)
|
||||||
|
|
||||||
|
To format and lint, run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx biome format --write ./app
|
||||||
|
bunx biome lint --changed --write ./app
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both before submitting a PR. CI will catch style issues, but it is better to fix them locally first.
|
||||||
|
|
||||||
|
## Submitting Changes
|
||||||
|
|
||||||
|
1. Open a GitHub Issue describing your proposed change, or link to a Discord thread where it was discussed. Wait for feedback before writing code.
|
||||||
|
2. Fork the repo and create a branch from `main`.
|
||||||
|
3. Keep changes focused and minimal. One PR per issue.
|
||||||
|
4. Add or update tests if applicable.
|
||||||
|
5. Run the test suite and linter before pushing.
|
||||||
|
6. Open a pull request against `main`. Reference the issue number in the PR description.
|
||||||
|
|
||||||
|
Expect a response from maintainers, but be patient -- this is an actively developed project and review may take some time.
|
||||||
|
|
||||||
|
## AI-Generated Code
|
||||||
|
|
||||||
|
If you use AI tools to write or assist with your code, you must:
|
||||||
|
|
||||||
|
- **Fully review all AI-generated code yourself** before submitting. You are responsible for understanding and standing behind every line in your PR.
|
||||||
|
- **Include the prompts used** in the PR description. This gives maintainers additional context for reviewing the code.
|
||||||
|
|
||||||
|
Pull requests with unreviewed AI-generated code will be closed.
|
||||||
|
|
||||||
|
## Contributing to Documentation
|
||||||
|
|
||||||
|
The documentation site lives in the `docs/` directory and is a **separate Next.js application** built with [Fumadocs](https://fumadocs.dev). It uses **npm** (not Bun) as its package manager.
|
||||||
|
|
||||||
|
### Docs Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The docs dev server runs on `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Where Documentation Lives
|
||||||
|
|
||||||
|
Documentation content is written in MDX and located in `docs/content/docs/`. The directory is organized into:
|
||||||
|
|
||||||
|
- `(documentation)/` -- core documentation pages
|
||||||
|
- `guide/` -- guides and tutorials
|
||||||
|
- `api-reference/` -- API reference (partially auto-generated from OpenAPI)
|
||||||
|
|
||||||
|
To add or edit a page, create or modify an `.mdx` file in the appropriate directory. Page metadata is defined via frontmatter at the top of each file. Navigation order is controlled by `meta.json` files in each directory.
|
||||||
|
|
||||||
|
### Building the Docs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates the OpenAPI and MCP reference pages before building the Next.js site. Make sure the build succeeds locally before submitting a docs PR.
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Open a GitHub Issue with:
|
||||||
|
|
||||||
|
- A clear title describing the problem.
|
||||||
|
- Steps to reproduce the bug.
|
||||||
|
- Expected behavior vs. actual behavior.
|
||||||
|
- Your environment (runtime, database, adapter, bknd version).
|
||||||
|
- Any relevant error messages or logs.
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
||||||
|
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
||||||
|
- **GitHub Issues**: https://github.com/bknd-io/bknd/issues
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who has contributed to bknd.
|
||||||
|
|
||||||
|
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|
||||||
|
## Maintainers
|
||||||
|
|
||||||
|
- **Dennis** ([@dswbx](https://github.com/dswbx)) -- Creator and maintainer of bknd
|
||||||
|
- **Cameron Pak** ([@cameronapak](https://github.com/cameronapak)) -- Maintainer of bknd docs
|
||||||
+169
-77
@@ -1,110 +1,202 @@
|
|||||||
# Functional Source License, Version 1.1, MIT Future License
|
|
||||||
|
|
||||||
## Abbreviation
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
FSL-1.1-MIT
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
## Notice
|
1. Definitions.
|
||||||
|
|
||||||
Copyright 2025 Dennis Senn
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
## Terms and Conditions
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
### Licensor ("We")
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
The party offering the Software under these Terms and Conditions.
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
### The Software
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
The "Software" is each version of the software that we make available under
|
"Object" form shall mean any form resulting from mechanical
|
||||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
transformation or translation of a Source form, including but
|
||||||
Conditions with the Software.
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
### License Grant
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
Subject to your compliance with this License Grant and the Patents,
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
form, that is based on (or derived from) the Work and for which the
|
||||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
and redistribute the Software for any Permitted Purpose identified below.
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
### Permitted Purpose
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
means making the Software available to others in a commercial product or
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
service that:
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
1. substitutes for the Software;
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
2. substitutes for any other product or service we offer using the Software
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
that exists as of the date we make the Software available; or
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
3. offers the same or substantially similar functionality as the Software.
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
Permitted Purposes specifically include using the Software:
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
1. for your internal use and access;
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
2. for non-commercial education;
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
3. for non-commercial research; and
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
4. in connection with professional services that you provide to a licensee
|
You may add Your own copyright statement to Your modifications and
|
||||||
using the Software in accordance with these Terms and Conditions.
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
### Patents
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
patents, the license grant above includes a license under our patents. If you
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
make a claim against any party that the Software infringes or contributes to
|
except as required for reasonable and customary use in describing the
|
||||||
the infringement of any patent, then your patent license to the Software ends
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
immediately.
|
|
||||||
|
|
||||||
### Redistribution
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
the Software.
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
If you redistribute any copies, modifications or derivatives of the Software,
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
you must include a copy of or a link to these Terms and Conditions and not
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
remove any copyright notices provided in or with the Software.
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
### Disclaimer
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
||||||
|
|
||||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
To apply the Apache License to your work, attach the following
|
||||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
### Trademarks
|
Copyright 2026 Dennis Senn
|
||||||
|
|
||||||
Except for displaying the License Details and identifying us as the origin of
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
the Software, you have no right under these Terms and Conditions to use our
|
you may not use this file except in compliance with the License.
|
||||||
trademarks, trade names, service marks or product names.
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
## Grant of Future License
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
We hereby irrevocably grant you an additional license to use the Software under
|
Unless required by applicable law or agreed to in writing, software
|
||||||
the MIT license that is effective on the second anniversary of the date we make
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
the Software available. On or after that date, you may use the Software under
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
the MIT license, in which case the following will apply:
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
||||||
of the Software, and to permit persons to whom the Software is furnished to do
|
|
||||||
so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -165,3 +165,19 @@ npx bknd run
|
|||||||
```bash
|
```bash
|
||||||
npm install bknd
|
npm install bknd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes.
|
||||||
|
|
||||||
|
Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who has contributed to bknd.
|
||||||
|
|
||||||
|
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
__test__/helper.d.ts
|
__test__/helper.d.ts
|
||||||
|
.env.local
|
||||||
@@ -1,66 +1,76 @@
|
|||||||
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
||||||
import * as adapter from "adapter";
|
import * as adapter from "adapter";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import { omitKeys } from "core/utils";
|
|
||||||
|
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
|
||||||
|
omitKeys(cfg, ["connection"]);
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
it("makes config", async () => {
|
describe("makeConfig", () => {
|
||||||
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
it("returns empty config for empty inputs", async () => {
|
||||||
expect(
|
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
||||||
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
[{}],
|
||||||
).toEqual({});
|
[{}, { env: { TEST: "test" } }],
|
||||||
|
];
|
||||||
|
|
||||||
// merges everything returned from `app` with the config
|
for (const args of cases) {
|
||||||
expect(
|
const cfg = await adapter.makeConfig(...(args as any));
|
||||||
omitKeys(
|
expect(stripConnection(cfg)).toEqual({});
|
||||||
await adapter.makeConfig(
|
}
|
||||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
});
|
||||||
{ env: { TEST: "test" } },
|
|
||||||
),
|
|
||||||
["connection"],
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows all properties in app function", async () => {
|
it("merges app output into config", async () => {
|
||||||
const called = mock(() => null);
|
const cfg = await adapter.makeConfig(
|
||||||
const config = await adapter.makeConfig(
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
{
|
{ env: { TEST: "test" } },
|
||||||
app: (env) => ({
|
|
||||||
connection: { url: "test" },
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
|
||||||
options: {
|
|
||||||
mode: "db",
|
|
||||||
},
|
|
||||||
onBuilt: () => {
|
|
||||||
called();
|
|
||||||
expect(env).toEqual({ foo: "bar" });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ foo: "bar" },
|
|
||||||
);
|
);
|
||||||
expect(config.connection).toEqual({ url: "test" });
|
|
||||||
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
|
|
||||||
expect(config.options).toEqual({ mode: "db" });
|
|
||||||
await config.onBuilt?.(null as any);
|
|
||||||
expect(called).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
expect(stripConnection(cfg)).toEqual({
|
||||||
|
config: { server: { cors: { origin: "test" } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows all properties in app() result", async () => {
|
||||||
|
const called = mock(() => null);
|
||||||
|
|
||||||
|
const cfg = await adapter.makeConfig(
|
||||||
|
{
|
||||||
|
app: (env) => ({
|
||||||
|
connection: { url: "test" },
|
||||||
|
config: { server: { cors: { origin: "test" } } },
|
||||||
|
options: { mode: "db" as const },
|
||||||
|
onBuilt: () => {
|
||||||
|
called();
|
||||||
|
expect(env).toEqual({ foo: "bar" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ foo: "bar" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cfg.connection).toEqual({ url: "test" });
|
||||||
|
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||||
|
expect(cfg.options).toEqual({ mode: "db" });
|
||||||
|
|
||||||
|
await cfg.onBuilt?.({} as any);
|
||||||
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adapter test suites", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createFrameworkApp,
|
makeApp: adapter.createFrameworkApp,
|
||||||
label: "framework app",
|
label: "framework app",
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createRuntimeApp,
|
makeApp: adapter.createRuntimeApp,
|
||||||
label: "runtime app",
|
label: "runtime app",
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -70,4 +70,10 @@ describe("Api", async () => {
|
|||||||
expect(params.token_transport).toBe("header");
|
expect(params.token_transport).toBe("header");
|
||||||
expect(params.host).toBe("http://another.com");
|
expect(params.host).toBe("http://another.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should extract tokens case insensitive", async () => {
|
||||||
|
const token = await sign({ sub: "test" }, "1234");
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
})
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="@types/bun" />
|
/// <reference types="@types/bun" />
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
|
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { assetsPath, assetsTmpPath } from "../helper";
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
|
|||||||
expect(isReadableStream(res.body)).toBe(true);
|
expect(isReadableStream(res.body)).toBe(true);
|
||||||
expect(isReadableStream(res.res.body)).toBe(true);
|
expect(isReadableStream(res.res.body)).toBe(true);
|
||||||
|
|
||||||
const blob = await res.res.blob();
|
const blob = (await res.res.blob()) as File;
|
||||||
expect(isFile(blob)).toBe(true);
|
expect(isFile(blob)).toBe(true);
|
||||||
expect(blob.size).toBeGreaterThan(0);
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
expect(blob.type).toBe("image/png");
|
expect(blob.type).toBe("image/png");
|
||||||
@@ -110,10 +110,11 @@ describe("MediaApi", () => {
|
|||||||
const api = new MediaApi({}, mockedBackend.request);
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
const name = "image.png";
|
const name = "image.png";
|
||||||
const res = await api.getFileStream(name);
|
const res = await api.getFile(name);
|
||||||
expect(isReadableStream(res)).toBe(true);
|
const stream = await api.getFileStream(name);
|
||||||
|
expect(isReadableStream(stream)).toBe(true);
|
||||||
|
|
||||||
const blob = await new Response(res).blob();
|
const blob = (await res.res.blob()) as File;
|
||||||
expect(isFile(blob)).toBe(true);
|
expect(isFile(blob)).toBe(true);
|
||||||
expect(blob.size).toBeGreaterThan(0);
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
expect(blob.type).toBe("image/png");
|
expect(blob.type).toBe("image/png");
|
||||||
@@ -162,4 +163,30 @@ describe("MediaApi", () => {
|
|||||||
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should add overwrite query for entity upload", async (c) => {
|
||||||
|
const call = mock(() => null);
|
||||||
|
const hono = new Hono().post(
|
||||||
|
"/api/media/entity/:entity/:id/:field",
|
||||||
|
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||||
|
async (c) => {
|
||||||
|
const { overwrite } = c.req.valid("query");
|
||||||
|
expect(overwrite).toBe(true);
|
||||||
|
call();
|
||||||
|
return c.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const api = new MediaApi(
|
||||||
|
{
|
||||||
|
upload_fetcher: hono.request,
|
||||||
|
},
|
||||||
|
hono.request,
|
||||||
|
);
|
||||||
|
const file = Bun.file(`${assetsPath}/image.png`);
|
||||||
|
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(call).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -38,4 +38,27 @@ describe("Authenticator", async () => {
|
|||||||
expect(cookie).toStartWith("auth=");
|
expect(cookie).toStartWith("auth=");
|
||||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bearer token is case insensitive", async () => {
|
||||||
|
const auth = new Authenticator({}, null as any, {
|
||||||
|
jwt: {
|
||||||
|
secret: "secret",
|
||||||
|
fields: ["sub"],
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
sameSite: "strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const token = await auth.jwt({ sub: "test" });
|
||||||
|
|
||||||
|
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res as any).sub).toBe("test")
|
||||||
|
|
||||||
|
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res2 as any).sub).toBe("test")
|
||||||
|
})
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
describe("PasswordStrategy", () => {
|
||||||
|
it("should enforce provided minimum length", async () => {
|
||||||
|
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
|
||||||
|
|
||||||
|
expect(strategy.verify("password")({} as any)).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,12 +41,12 @@ describe("postgres", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (!(await isPostgresRunning())) {
|
if (!(await isPostgresRunning())) {
|
||||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
||||||
await $waitUntil("Postgres is running", isPostgresRunning);
|
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
disableConsoleLog();
|
disableConsoleLog();
|
||||||
});
|
}, 30000);
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (await isPostgresRunning()) {
|
if (await isPostgresRunning()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
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" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "author"."id" asc limit ?) as obj) as "author" from "posts"',
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1]);
|
expect(res2.parameters).toEqual([1]);
|
||||||
});
|
});
|
||||||
@@ -192,9 +192,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ single: {} },
|
{ single: {} },
|
||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toMatchSnapshot();
|
||||||
'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 ?) as obj) as "single" from "categories"',
|
|
||||||
);
|
|
||||||
expect(res.parameters).toEqual(["categories.single", 1]);
|
expect(res.parameters).toEqual(["categories.single", 1]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
@@ -204,9 +202,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ multiple: {} },
|
{ multiple: {} },
|
||||||
);
|
);
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toMatchSnapshot();
|
||||||
'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", 10, 0]);
|
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,9 +267,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
//prettyPrintQb(qb);
|
//prettyPrintQb(qb);
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'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 ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
|
|
||||||
);
|
|
||||||
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,9 +307,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'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 ?) 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, 12, 0]);
|
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with singles 1`] = `"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 "avatar"."id" as "id", "avatar"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "avatar"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with many 1`] = `"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 ?) 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""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 1`] = `"select (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "single"."id" as "id", "single"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "single"."id" asc limit ?) as obj) as "single" from "categories""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 2`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'path', "agg"."path")), '[]') from (select "multiple"."id" as "id", "multiple"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "multiple"."id" asc limit ? offset ?) as agg) as "multiple" from "categories""`;
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { App, createApp, type AuthResponse } from "../../src";
|
import { App, createApp, type AuthResponse } from "../../src";
|
||||||
import { auth } from "../../src/modules/middlewares";
|
import { auth } from "../../src/modules/middlewares";
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
import {
|
||||||
|
mergeObject,
|
||||||
|
randomString,
|
||||||
|
secureRandomString,
|
||||||
|
withDisabledConsole,
|
||||||
|
} from "../../src/core/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -62,12 +68,12 @@ const configs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createAuthApp() {
|
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
config: {
|
config: {
|
||||||
auth: configs.auth,
|
auth: mergeObject(configs.auth, config ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
|||||||
|
|
||||||
return { res, data };
|
return { res, data };
|
||||||
},
|
},
|
||||||
|
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/register", {
|
||||||
|
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">> => {
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
const res = (await app.server.request("/api/auth/me", {
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -245,4 +261,61 @@ describe("integration auth", () => {
|
|||||||
expect(await $fns.me()).toEqual({ user: null as any });
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should register users with default role", async () => {
|
||||||
|
const app = createAuthApp({ default_role_register: "guest" });
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("guest");
|
||||||
|
|
||||||
|
// throws error if role doesn't exist
|
||||||
|
expect(
|
||||||
|
app.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "doesnt exist",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// takes role if provided
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test2@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("admin");
|
||||||
|
|
||||||
|
// registering with role is not allowed
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.res.ok),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.data.user.role),
|
||||||
|
).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { TAppMediaConfig } from "../../src/media/media-schema";
|
|||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { assetsPath, assetsTmpPath } from "../helper";
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
import * as proto from "data/prototype";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
disableConsoleLog();
|
disableConsoleLog();
|
||||||
@@ -128,4 +129,87 @@ describe("MediaController", () => {
|
|||||||
expect(destFile.exists()).resolves.toBe(true);
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
await destFile.delete();
|
await destFile.delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("entity upload with max_items and overwrite", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
media: mergeObject(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: assetsTmpPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
entities: {
|
||||||
|
posts: proto
|
||||||
|
.entity("posts", {
|
||||||
|
title: proto.text(),
|
||||||
|
cover: proto.medium(),
|
||||||
|
})
|
||||||
|
.toJSON(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
// create a post first
|
||||||
|
const createRes = await app.server.request("/api/data/entity/posts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Test Post" }),
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const { data: post } = (await createRes.json()) as any;
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const uploadedFiles: string[] = [];
|
||||||
|
|
||||||
|
// upload first file to entity (should succeed)
|
||||||
|
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(201);
|
||||||
|
const result1 = (await res1.json()) as any;
|
||||||
|
uploadedFiles.push(result1.name);
|
||||||
|
|
||||||
|
// upload second file without overwrite (should fail - max_items reached)
|
||||||
|
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
const result2 = (await res2.json()) as any;
|
||||||
|
expect(result2.error).toContain("Max items");
|
||||||
|
|
||||||
|
// upload third file with overwrite=true (should succeed and delete old file)
|
||||||
|
const res3 = await app.server.request(
|
||||||
|
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(res3.status).toBe(201);
|
||||||
|
const result3 = (await res3.json()) as any;
|
||||||
|
uploadedFiles.push(result3.name);
|
||||||
|
|
||||||
|
// verify old file was deleted from storage
|
||||||
|
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
|
||||||
|
expect(await oldFile.exists()).toBe(false);
|
||||||
|
|
||||||
|
// verify new file exists
|
||||||
|
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
|
||||||
|
expect(await newFile.exists()).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
await newFile.delete();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -149,7 +149,7 @@ describe("AppAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
app.registerAdminController();
|
app.registerAdminController({ forceDev: true });
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
@@ -223,4 +223,32 @@ describe("AppAuth", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default role for registration must be a valid role", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "123456",
|
||||||
|
},
|
||||||
|
allow_register: true,
|
||||||
|
roles: {
|
||||||
|
guest: {
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const auth = app.module.auth;
|
||||||
|
// doesn't allow invalid role
|
||||||
|
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
|
||||||
|
// allows valid role
|
||||||
|
await auth.schema().patch("default_role_register", "guest");
|
||||||
|
expect(auth.toJSON().default_role_register).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,9 @@ import v7 from "./samples/v7.json";
|
|||||||
import v8 from "./samples/v8.json";
|
import v8 from "./samples/v8.json";
|
||||||
import v8_2 from "./samples/v8-2.json";
|
import v8_2 from "./samples/v8-2.json";
|
||||||
import v9 from "./samples/v9.json";
|
import v9 from "./samples/v9.json";
|
||||||
|
import v10 from "./samples/v10.json";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
import { CURRENT_VERSION } from "modules/db/migrations";
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -61,7 +63,7 @@ async function getRawConfig(
|
|||||||
return await db
|
return await db
|
||||||
.selectFrom("__bknd")
|
.selectFrom("__bknd")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
.where("version", "=", opts?.version ?? CURRENT_VERSION)
|
||||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,6 @@ describe("Migrations", () => {
|
|||||||
"^^s3.secret_access_key^^",
|
"^^s3.secret_access_key^^",
|
||||||
);
|
);
|
||||||
const [config, secrets] = (await getRawConfig(app, {
|
const [config, secrets] = (await getRawConfig(app, {
|
||||||
version: 10,
|
|
||||||
types: ["config", "secrets"],
|
types: ["config", "secrets"],
|
||||||
})) as any;
|
})) as any;
|
||||||
|
|
||||||
@@ -129,4 +130,15 @@ describe("Migrations", () => {
|
|||||||
"^^s3.secret_access_key^^",
|
"^^s3.secret_access_key^^",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("migration from 10 to 11", async () => {
|
||||||
|
expect(v10.version).toBe(10);
|
||||||
|
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
|
||||||
|
|
||||||
|
const app = await createVersionedApp(v10);
|
||||||
|
|
||||||
|
expect(app.version()).toBeGreaterThan(10);
|
||||||
|
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
|
||||||
|
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
{
|
||||||
|
"version": 10,
|
||||||
|
"server": {
|
||||||
|
"cors": {
|
||||||
|
"origin": "*",
|
||||||
|
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||||
|
"allow_headers": [
|
||||||
|
"Content-Type",
|
||||||
|
"Content-Length",
|
||||||
|
"Authorization",
|
||||||
|
"Accept"
|
||||||
|
],
|
||||||
|
"allow_credentials": true
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "/api/system/mcp",
|
||||||
|
"logLevel": "warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"basepath": "/api/data",
|
||||||
|
"default_primary_format": "integer",
|
||||||
|
"entities": {
|
||||||
|
"test": {
|
||||||
|
"type": "regular",
|
||||||
|
"fields": {
|
||||||
|
"id": {
|
||||||
|
"type": "primary",
|
||||||
|
"config": {
|
||||||
|
"format": "integer",
|
||||||
|
"fillable": false,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": ["read", "update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "enum",
|
||||||
|
"config": {
|
||||||
|
"default_value": "INACTIVE",
|
||||||
|
"options": {
|
||||||
|
"type": "strings",
|
||||||
|
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "date",
|
||||||
|
"config": {
|
||||||
|
"type": "datetime",
|
||||||
|
"required": true,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "jsonschema",
|
||||||
|
"config": {
|
||||||
|
"default_from_schema": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"one": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort_field": "id",
|
||||||
|
"sort_dir": "asc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "regular",
|
||||||
|
"fields": {
|
||||||
|
"id": {
|
||||||
|
"type": "primary",
|
||||||
|
"config": {
|
||||||
|
"format": "integer",
|
||||||
|
"fillable": false,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort_field": "id",
|
||||||
|
"sort_dir": "asc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"type": "system",
|
||||||
|
"fields": {
|
||||||
|
"id": {
|
||||||
|
"type": "primary",
|
||||||
|
"config": {
|
||||||
|
"format": "integer",
|
||||||
|
"fillable": false,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": true,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"type": "boolean",
|
||||||
|
"config": {
|
||||||
|
"default_value": false,
|
||||||
|
"hidden": true,
|
||||||
|
"fillable": ["create"],
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mime_type": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"type": "number",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"hidden": true,
|
||||||
|
"fillable": ["create"],
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"etag": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modified_at": {
|
||||||
|
"type": "date",
|
||||||
|
"config": {
|
||||||
|
"type": "datetime",
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"type": "text",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "json",
|
||||||
|
"config": {
|
||||||
|
"required": false,
|
||||||
|
"fillable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort_field": "id",
|
||||||
|
"sort_dir": "asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relations": {},
|
||||||
|
"indices": {
|
||||||
|
"idx_unique_media_path": {
|
||||||
|
"entity": "media",
|
||||||
|
"fields": ["path"],
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
"idx_media_reference": {
|
||||||
|
"entity": "media",
|
||||||
|
"fields": ["reference"],
|
||||||
|
"unique": false
|
||||||
|
},
|
||||||
|
"idx_media_entity_id": {
|
||||||
|
"entity": "media",
|
||||||
|
"fields": ["entity_id"],
|
||||||
|
"unique": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": false,
|
||||||
|
"basepath": "/api/auth",
|
||||||
|
"entity_name": "users",
|
||||||
|
"allow_register": true,
|
||||||
|
"jwt": {
|
||||||
|
"secret": "",
|
||||||
|
"alg": "HS256",
|
||||||
|
"fields": ["id", "email", "role"]
|
||||||
|
},
|
||||||
|
"cookie": {
|
||||||
|
"path": "/",
|
||||||
|
"sameSite": "lax",
|
||||||
|
"secure": true,
|
||||||
|
"httpOnly": true,
|
||||||
|
"expires": 604800,
|
||||||
|
"partitioned": false,
|
||||||
|
"renew": true,
|
||||||
|
"pathSuccess": "/",
|
||||||
|
"pathLoggedOut": "/"
|
||||||
|
},
|
||||||
|
"strategies": {
|
||||||
|
"password": {
|
||||||
|
"type": "password",
|
||||||
|
"enabled": true,
|
||||||
|
"config": {
|
||||||
|
"hashing": "sha256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guard": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"roles": {}
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"flows": {
|
||||||
|
"basepath": "/api/flows",
|
||||||
|
"flows": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { AppReduced, type AppType } from "ui/client/utils/AppReduced";
|
||||||
|
import type { BkndAdminProps } from "ui/Admin";
|
||||||
|
|
||||||
|
// Import the normalizeAdminPath function for testing
|
||||||
|
// Note: This assumes the function is exported or we need to test it indirectly through public methods
|
||||||
|
|
||||||
|
describe("AppReduced", () => {
|
||||||
|
let mockAppJson: AppType;
|
||||||
|
let appReduced: AppReduced;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAppJson = {
|
||||||
|
data: {
|
||||||
|
entities: {},
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
flows: {
|
||||||
|
flows: {},
|
||||||
|
},
|
||||||
|
auth: {},
|
||||||
|
} as AppType;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettingsPath", () => {
|
||||||
|
it("should return settings path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return settings path with empty basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append additional path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["user", "profile"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user/profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize multiple slashes", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//user//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle basepath without leading slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAbsolutePath", () => {
|
||||||
|
it("should return absolute path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return base path when no path provided", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize paths correctly", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("//dashboard//");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("options getter", () => {
|
||||||
|
it("should return merged options with defaults", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options).toEqual({
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default logo_return_path when not provided", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options.logo_return_path).toBe("/");
|
||||||
|
expect(options.basepath).toBe("/admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path normalization behavior", () => {
|
||||||
|
it("should normalize duplicate slashes in settings path", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//nested//path//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/nested/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle root path normalization", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
// The normalizeAdminPath function removes trailing slashes except for root "/"
|
||||||
|
// When basepath is "/", the result is "~/" which becomes "~" after normalization
|
||||||
|
expect(result).toBe("~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve entity paths ending with slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("entity/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/entity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove trailing slashes from non-entity paths", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => {
|
||||||
|
it("should not produce double slashes when admin_basepath has trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work correctly when admin_basepath has no trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle absolute paths with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("data");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle settings path with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["general"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/general");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle undefined basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
// When basepath is undefined, it defaults to empty string
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["", "valid", ""]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/valid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-12
@@ -3,17 +3,7 @@ import c from "picocolors";
|
|||||||
import { formatNumber } from "bknd/utils";
|
import { formatNumber } from "bknd/utils";
|
||||||
|
|
||||||
const deps = Object.keys(pkg.dependencies);
|
const deps = Object.keys(pkg.dependencies);
|
||||||
const external = [
|
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||||
"jsonv-ts/*",
|
|
||||||
"wrangler",
|
|
||||||
"bknd",
|
|
||||||
"bknd/*",
|
|
||||||
"@vitejs/plugin-react",
|
|
||||||
"vite",
|
|
||||||
"@tailwindcss/vite",
|
|
||||||
"@cloudflare/vite-plugin",
|
|
||||||
...deps,
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/cli/index.ts"],
|
entrypoints: ["./src/cli/index.ts"],
|
||||||
@@ -21,7 +11,6 @@ const result = await Bun.build({
|
|||||||
outdir: "./dist/cli",
|
outdir: "./dist/cli",
|
||||||
env: "PUBLIC_*",
|
env: "PUBLIC_*",
|
||||||
minify: true,
|
minify: true,
|
||||||
banner: `const __originalLog=console.log;console.log=(...o)=>{const n=o[0];"string"==typeof n&&n.includes("[dotenv@")||__originalLog.apply(console,o)};`,
|
|
||||||
external,
|
external,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
|
|||||||
+353
-255
@@ -2,6 +2,8 @@ import { $ } from "bun";
|
|||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -12,163 +14,178 @@ const clean = args.includes("--clean");
|
|||||||
|
|
||||||
// silence tsup
|
// silence tsup
|
||||||
const oldConsole = {
|
const oldConsole = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
};
|
};
|
||||||
console.log = () => {};
|
console.log = () => {};
|
||||||
console.warn = () => {};
|
console.warn = () => {};
|
||||||
|
|
||||||
const define = {
|
const define = {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.info("Cleaning dist (w/o static)");
|
console.info("Cleaning dist (w/o static)");
|
||||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
// Cross-platform clean: remove all files/folders in dist except static
|
||||||
|
const distPath = join(import.meta.dir, "dist");
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(distPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === "static") continue;
|
||||||
|
const entryPath = join(distPath, entry);
|
||||||
|
rmSync(entryPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// dist may not exist yet, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
function buildTypes() {
|
function buildTypes() {
|
||||||
if (types_running || !types) return;
|
if (types_running || !types) return;
|
||||||
types_running = true;
|
types_running = true;
|
||||||
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types && !watch) {
|
if (types && !watch) {
|
||||||
buildTypes();
|
buildTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch || !types) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencies = Object.keys(pkg.dependencies);
|
const dependencies = Object.keys(pkg.dependencies);
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = [
|
const external = [
|
||||||
...dependencies,
|
...dependencies,
|
||||||
"bun:test",
|
"bun:test",
|
||||||
"node:test",
|
"node:test",
|
||||||
"node:assert/strict",
|
"node:assert/strict",
|
||||||
"@libsql/client",
|
"@libsql/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"jsonv-ts",
|
"jsonv-ts",
|
||||||
/^jsonv-ts\/.*/,
|
/^jsonv-ts\/.*/,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
async function buildApi() {
|
async function buildApi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
// don't use tsup's broken watch, we'll handle it ourselves
|
||||||
define,
|
watch: false,
|
||||||
entry: [
|
define,
|
||||||
"src/index.ts",
|
entry: [
|
||||||
"src/core/utils/index.ts",
|
"src/index.ts",
|
||||||
"src/plugins/index.ts",
|
"src/core/utils/index.ts",
|
||||||
"src/modes/index.ts",
|
"src/plugins/index.ts",
|
||||||
],
|
"src/modes/index.ts",
|
||||||
outDir: "dist",
|
],
|
||||||
external: [...external],
|
outDir: "dist",
|
||||||
metafile: true,
|
external: [...external],
|
||||||
target: "esnext",
|
metafile: true,
|
||||||
platform: "browser",
|
target: "esnext",
|
||||||
removeNodeProtocol: false,
|
platform: "browser",
|
||||||
format: ["esm"],
|
removeNodeProtocol: false,
|
||||||
splitting: false,
|
format: ["esm"],
|
||||||
loader: {
|
splitting: false,
|
||||||
".svg": "dataurl",
|
loader: {
|
||||||
},
|
".svg": "dataurl",
|
||||||
onSuccess: async () => {
|
},
|
||||||
delayTypes();
|
onSuccess: async () => {
|
||||||
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
delayTypes();
|
||||||
},
|
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
||||||
});
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteClient(path: string) {
|
async function rewriteClient(path: string) {
|
||||||
const bundle = await Bun.file(path).text();
|
const bundle = await Bun.file(path).text();
|
||||||
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
await Bun.write(
|
||||||
|
path,
|
||||||
|
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
async function buildUi() {
|
async function buildUi() {
|
||||||
const base = {
|
const base = {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
/codemirror/,
|
/codemirror/,
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
"@mantine/core",
|
"@mantine/core",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.logLevel = "silent";
|
options.logLevel = "silent";
|
||||||
},
|
},
|
||||||
} satisfies tsup.Options;
|
} satisfies tsup.Options;
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/index.js");
|
await rewriteClient("./dist/ui/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/client/index.ts"],
|
entry: ["src/ui/client/index.ts"],
|
||||||
outDir: "dist/ui/client",
|
outDir: "dist/ui/client",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/client/index.js");
|
await rewriteClient("./dist/ui/client/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,162 +194,243 @@ async function buildUi() {
|
|||||||
* - ui/client is external, and after built replaced with "bknd/client"
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
*/
|
*/
|
||||||
async function buildUiElements() {
|
async function buildUiElements() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"wouter",
|
"wouter",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
// not important for elements, mock to reduce bundle
|
// not important for elements, mock to reduce bundle
|
||||||
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/elements/index.js");
|
await rewriteClient("./dist/ui/elements/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
function baseConfig(
|
||||||
return {
|
adapter: string,
|
||||||
minify,
|
overrides: Partial<tsup.Options> = {},
|
||||||
sourcemap,
|
): tsup.Options {
|
||||||
watch,
|
return {
|
||||||
entry: [`src/adapter/${adapter}/index.ts`],
|
minify,
|
||||||
format: ["esm"],
|
sourcemap,
|
||||||
platform: "neutral",
|
watch: false,
|
||||||
outDir: `dist/adapter/${adapter}`,
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
metafile: true,
|
format: ["esm"],
|
||||||
splitting: false,
|
platform: "neutral",
|
||||||
removeNodeProtocol: false,
|
outDir: `dist/adapter/${adapter}`,
|
||||||
onSuccess: async () => {
|
metafile: true,
|
||||||
delayTypes();
|
splitting: false,
|
||||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
removeNodeProtocol: false,
|
||||||
},
|
onSuccess: async () => {
|
||||||
...overrides,
|
delayTypes();
|
||||||
define: {
|
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||||
...define,
|
},
|
||||||
...overrides.define,
|
...overrides,
|
||||||
},
|
define: {
|
||||||
external: [
|
...define,
|
||||||
/^cloudflare*/,
|
...overrides.define,
|
||||||
/^@?hono.*?/,
|
},
|
||||||
/^(bknd|react|next|node).*?/,
|
external: [
|
||||||
/.*\.(html)$/,
|
/^cloudflare*/,
|
||||||
...external,
|
/^@?hono.*?/,
|
||||||
...(Array.isArray(overrides.external) ? overrides.external : []),
|
/^(bknd|react|next|node).*?/,
|
||||||
],
|
/.*\.(html)$/,
|
||||||
};
|
...external,
|
||||||
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAdapters() {
|
async function buildAdapters() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// base adapter handles
|
// base adapter handles
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig(""),
|
...baseConfig(""),
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
entry: ["src/adapter/index.ts"],
|
entry: ["src/adapter/index.ts"],
|
||||||
outDir: "dist/adapter",
|
outDir: "dist/adapter",
|
||||||
// only way to keep @vite-ignore comments
|
// only way to keep @vite-ignore comments
|
||||||
minify: false,
|
minify: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("browser", {
|
baseConfig("browser", {
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
external: [/^sqlocal\/?.*?/, "wouter"],
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("bun", {
|
|
||||||
external: [/^bun\:.*/],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(baseConfig("astro")),
|
|
||||||
tsup.build(baseConfig("aws")),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare", {
|
|
||||||
external: ["wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare/proxy", {
|
|
||||||
target: "esnext",
|
|
||||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
|
||||||
outDir: "dist/adapter/cloudflare",
|
|
||||||
metafile: false,
|
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("vite"),
|
|
||||||
platform: "node",
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("nextjs"),
|
baseConfig("bun", {
|
||||||
platform: "node",
|
external: [/^bun\:.*/],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(baseConfig("astro")),
|
||||||
...baseConfig("node"),
|
tsup.build(baseConfig("aws")),
|
||||||
platform: "node",
|
tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: ["wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("sqlite/edge"),
|
baseConfig("cloudflare/proxy", {
|
||||||
entry: ["src/adapter/sqlite/edge.ts"],
|
target: "esnext",
|
||||||
outDir: "dist/adapter/sqlite",
|
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||||
metafile: false,
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/node"),
|
...baseConfig("vite"),
|
||||||
entry: ["src/adapter/sqlite/node.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
platform: "node",
|
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/bun"),
|
...baseConfig("nextjs"),
|
||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
metafile: false,
|
|
||||||
external: [/^bun\:.*/],
|
tsup.build({
|
||||||
}),
|
...baseConfig("tanstack-start"),
|
||||||
]);
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sveltekit"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("nuxt"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("node"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("solid-start"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/edge"),
|
||||||
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/node"),
|
||||||
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/bun"),
|
||||||
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
external: [/^bun\:.*/],
|
||||||
|
}),
|
||||||
|
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
async function buildAll() {
|
||||||
|
await Promise.all([
|
||||||
|
buildApi(),
|
||||||
|
buildUi(),
|
||||||
|
buildUiElements(),
|
||||||
|
buildAdapters(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial build
|
||||||
|
await buildAll();
|
||||||
|
|
||||||
|
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||||
|
if (watch) {
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isBuilding = false;
|
||||||
|
|
||||||
|
const rebuild = async () => {
|
||||||
|
if (isBuilding) return;
|
||||||
|
isBuilding = true;
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||||
|
try {
|
||||||
|
await buildAll();
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||||
|
} catch (e) {
|
||||||
|
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||||
|
}
|
||||||
|
isBuilding = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedRebuild = () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(rebuild, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// watch src directory recursively
|
||||||
|
fsWatch(
|
||||||
|
join(import.meta.dir, "src"),
|
||||||
|
{ recursive: true },
|
||||||
|
(event, filename) => {
|
||||||
|
if (!filename) return;
|
||||||
|
// ignore non-source files
|
||||||
|
if (
|
||||||
|
!filename.endsWith(".ts") &&
|
||||||
|
!filename.endsWith(".tsx") &&
|
||||||
|
!filename.endsWith(".css")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||||
|
debouncedRebuild();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// keep process alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
+3
-2
@@ -3,10 +3,11 @@ import path from "node:path";
|
|||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
|
||||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
|
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
cmd: string[] | string,
|
cmd: string[] | string,
|
||||||
opts: Bun.SpawnOptions.OptionsObject & {},
|
opts: RunOptions,
|
||||||
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||||
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -17,7 +18,7 @@ async function run(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Read from stdout
|
// Read from stdout
|
||||||
const reader = (proc.stdout as ReadableStream).getReader();
|
const reader = proc.stdout.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
// Function to read chunks
|
// Function to read chunks
|
||||||
|
|||||||
+40
-31
@@ -1,44 +1,53 @@
|
|||||||
const adapter = process.env.TEST_ADAPTER;
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
const default_config = {
|
const default_config = {
|
||||||
media_adapter: "local",
|
media_adapter: "local",
|
||||||
base_path: "",
|
base_path: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const configs = {
|
const configs = {
|
||||||
cloudflare: {
|
cloudflare: {
|
||||||
media_adapter: "r2",
|
media_adapter: "r2",
|
||||||
},
|
},
|
||||||
"react-router": {
|
"react-router": {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
nuxt: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
"solid-start": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nextjs: {
|
astro: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
astro: {
|
node: {
|
||||||
base_path: "/admin",
|
base_path: "",
|
||||||
},
|
},
|
||||||
node: {
|
bun: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
bun: {
|
"tanstack-start": {
|
||||||
base_path: "",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAdapterConfig(): typeof default_config {
|
export function getAdapterConfig(): typeof default_config {
|
||||||
if (adapter) {
|
if (adapter) {
|
||||||
if (!configs[adapter]) {
|
if (!configs[adapter]) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...default_config,
|
...default_config,
|
||||||
...configs[adapter],
|
...configs[adapter],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default_config;
|
return default_config;
|
||||||
}
|
}
|
||||||
+34
-7
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.20.0-rc.1",
|
"version": "0.21.0",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"@xyflow/react": "^12.9.2",
|
"@xyflow/react": "^12.9.2",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"fast-xml-parser": "^5.3.1",
|
"fast-xml-parser": "^5.3.1",
|
||||||
"hono": "4.10.4",
|
"hono": "4.10.4",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
@@ -80,7 +80,6 @@
|
|||||||
"@aws-sdk/client-s3": "^3.922.0",
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
"@bluwy/giget-core": "^0.1.6",
|
"@bluwy/giget-core": "^0.1.6",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@cloudflare/vite-plugin": "^1.15.3",
|
|
||||||
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||||
"@cloudflare/workers-types": "^4.20251014.0",
|
"@cloudflare/workers-types": "^4.20251014.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
@@ -89,7 +88,7 @@
|
|||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/notifications": "^7.17.1",
|
"@mantine/notifications": "^7.17.1",
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.63.0",
|
||||||
"@rjsf/core": "5.22.2",
|
"@rjsf/core": "5.22.2",
|
||||||
"@rjsf/utils": "5.22.0",
|
"@rjsf/utils": "5.22.0",
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -143,7 +142,7 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "3.0.9",
|
"vitest": "3.0.9",
|
||||||
"wouter": "^3.7.1",
|
"wouter": "^3.7.1",
|
||||||
"wrangler": "^4.52.1"
|
"wrangler": "^4.45.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.19.6"
|
"@hono/node-server": "^1.19.6"
|
||||||
@@ -234,6 +233,11 @@
|
|||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.js"
|
"require": "./dist/adapter/nextjs/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/nuxt": {
|
||||||
|
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
||||||
|
"import": "./dist/adapter/nuxt/index.js",
|
||||||
|
"require": "./dist/adapter/nuxt/index.js"
|
||||||
|
},
|
||||||
"./adapter/react-router": {
|
"./adapter/react-router": {
|
||||||
"types": "./dist/types/adapter/react-router/index.d.ts",
|
"types": "./dist/types/adapter/react-router/index.d.ts",
|
||||||
"import": "./dist/adapter/react-router/index.js",
|
"import": "./dist/adapter/react-router/index.js",
|
||||||
@@ -254,6 +258,16 @@
|
|||||||
"import": "./dist/adapter/astro/index.js",
|
"import": "./dist/adapter/astro/index.js",
|
||||||
"require": "./dist/adapter/astro/index.js"
|
"require": "./dist/adapter/astro/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/sveltekit": {
|
||||||
|
"types": "./dist/types/adapter/sveltekit/index.d.ts",
|
||||||
|
"import": "./dist/adapter/sveltekit/index.js",
|
||||||
|
"require": "./dist/adapter/sveltekit/index.js"
|
||||||
|
},
|
||||||
|
"./adapter/solid-start": {
|
||||||
|
"types": "./dist/types/adapter/solid-start/index.d.ts",
|
||||||
|
"import": "./dist/adapter/solid-start/index.js",
|
||||||
|
"require": "./dist/adapter/solid-start/index.js"
|
||||||
|
},
|
||||||
"./adapter/aws": {
|
"./adapter/aws": {
|
||||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||||
"import": "./dist/adapter/aws/index.js",
|
"import": "./dist/adapter/aws/index.js",
|
||||||
@@ -264,6 +278,11 @@
|
|||||||
"import": "./dist/adapter/browser/index.js",
|
"import": "./dist/adapter/browser/index.js",
|
||||||
"require": "./dist/adapter/browser/index.js"
|
"require": "./dist/adapter/browser/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/tanstack-start": {
|
||||||
|
"types": "./dist/types/adapter/tanstack-start/index.d.ts",
|
||||||
|
"import": "./dist/adapter/tanstack-start/index.js",
|
||||||
|
"require": "./dist/adapter/tanstack-start/index.js"
|
||||||
|
},
|
||||||
"./dist/main.css": "./dist/ui/main.css",
|
"./dist/main.css": "./dist/ui/main.css",
|
||||||
"./dist/styles.css": "./dist/ui/styles.css",
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
||||||
@@ -278,9 +297,13 @@
|
|||||||
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||||
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||||
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||||
|
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
||||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||||
|
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||||
|
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
||||||
|
"adapter/solid-start": ["./dist/types/adapter/solid-start/index.d.ts"],
|
||||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -307,10 +330,14 @@
|
|||||||
"serverless",
|
"serverless",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
|
"nuxt",
|
||||||
"remix",
|
"remix",
|
||||||
"react-router",
|
"react-router",
|
||||||
"astro",
|
"astro",
|
||||||
|
"sveltekit",
|
||||||
|
"svelte",
|
||||||
"bun",
|
"bun",
|
||||||
"node"
|
"node",
|
||||||
|
"solid-start"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -121,7 +121,7 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try authorization header
|
// try authorization header
|
||||||
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
const headerToken = this.options.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
if (headerToken) {
|
if (headerToken) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
if (Connection.isConnection(config.connection)) {
|
if (Connection.isConnection(config.connection)) {
|
||||||
connection = config.connection;
|
connection = config.connection;
|
||||||
} else {
|
} else {
|
||||||
if (connection) {
|
|
||||||
$console.warn(
|
|
||||||
"Connection is not a valid connection object, using default SQLite connection",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||||
const conf = appConfig.connection ?? { url: "file:data.db" };
|
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||||
connection = sqlite(conf) as any;
|
connection = sqlite(conf) as any;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./nuxt.adapter";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as nuxt from "./nuxt.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("nuxt adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: nuxt.getApp,
|
||||||
|
makeHandler: nuxt.serve,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||||
|
|
||||||
|
export type NuxtEnv = NodeJS.ProcessEnv;
|
||||||
|
export type NuxtBkndConfig<Env = NuxtEnv> = RuntimeBkndConfig<Env>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bknd app instance
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export async function getApp<Env>(
|
||||||
|
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return await createRuntimeApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create middleware handler for Nuxt
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export function serve<Env>(
|
||||||
|
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return async (request: Request) => {
|
||||||
|
return (await getApp(config, args)).fetch(request);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./solid-start.adapter";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as solidStart from "./solid-start.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import type { SolidStartBkndConfig } from "./solid-start.adapter";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("solid-start adapter", () => {
|
||||||
|
adapterTestSuite<SolidStartBkndConfig>(bunTestRunner, {
|
||||||
|
makeApp: solidStart.getApp,
|
||||||
|
makeHandler: solidStart.serve,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||||
|
|
||||||
|
export type SolidStartEnv = NodeJS.ProcessEnv;
|
||||||
|
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bknd app instance
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export async function getApp<Env = SolidStartEnv>(
|
||||||
|
config: SolidStartBkndConfig<Env>,
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return await createRuntimeApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create middleware handler for Solid Start
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export function serve<Env = SolidStartEnv>(
|
||||||
|
config: SolidStartBkndConfig<Env> = {},
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return async (req: Request) => {
|
||||||
|
const app = await getApp(config, args);
|
||||||
|
return app.fetch(req);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./sveltekit.adapter";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as sveltekit from "./sveltekit.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("sveltekit adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
|
||||||
|
makeHandler: (c, a) => (request: Request) =>
|
||||||
|
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||||
|
|
||||||
|
type TSvelteKit = {
|
||||||
|
request: Request;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bknd app instance
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
||||||
|
*/
|
||||||
|
export async function getApp<Env>(
|
||||||
|
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return await createRuntimeApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create request handler for hooks.server.ts
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
||||||
|
*/
|
||||||
|
export function serve<Env>(
|
||||||
|
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return async (fnArgs: TSvelteKit) => {
|
||||||
|
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./tanstack-start.adapter";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as tanstackStart from "./tanstack-start.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import type { TanstackStartConfig } from "./tanstack-start.adapter";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("tanstack start adapter", () => {
|
||||||
|
adapterTestSuite<TanstackStartConfig>(bunTestRunner, {
|
||||||
|
makeApp: tanstackStart.getApp,
|
||||||
|
makeHandler: tanstackStart.serve,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
||||||
|
|
||||||
|
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
export type TanstackStartConfig<Env = TanstackStartEnv> =
|
||||||
|
FrameworkBkndConfig<Env>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bknd app instance
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export async function getApp<Env = TanstackStartEnv>(
|
||||||
|
config: TanstackStartConfig<Env> = {},
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return await createFrameworkApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create request handler for src/routes/api.$.ts
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export function serve<Env = TanstackStartEnv>(
|
||||||
|
config: TanstackStartConfig<Env> = {},
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return async (request: Request) => {
|
||||||
|
const app = await getApp(config, args);
|
||||||
|
return app.fetch(request);
|
||||||
|
};
|
||||||
|
}
|
||||||
+29
-1
@@ -46,6 +46,22 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
to.strategies!.password!.enabled = true;
|
to.strategies!.password!.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.default_role_register && to.default_role_register?.length > 0) {
|
||||||
|
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
|
||||||
|
|
||||||
|
if (!valid_to_role) {
|
||||||
|
const msg = `Default role for registration not found: ${to.default_role_register}`;
|
||||||
|
// if changing to a new value
|
||||||
|
if (from.default_role_register !== to.default_role_register) {
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetting gracefully, since role doesn't exist anymore
|
||||||
|
$console.warn(`${msg}, resetting to undefined`);
|
||||||
|
to.default_role_register = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +98,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||||
jwt: this.config.jwt,
|
jwt: this.config.jwt,
|
||||||
cookie: this.config.cookie,
|
cookie: this.config.cookie,
|
||||||
|
default_role_register: this.config.default_role_register,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerEntities();
|
this.registerEntities();
|
||||||
@@ -171,10 +188,20 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
async createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
...additional
|
||||||
|
}: CreateUserPayload): Promise<DB["users"]> {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
throw new Error("Cannot create user, auth not enabled");
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
}
|
}
|
||||||
|
if (role) {
|
||||||
|
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
|
||||||
|
throw new Error(`Role "${role}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const strategy = "password" as const;
|
const strategy = "password" as const;
|
||||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
@@ -183,6 +210,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
const { data: created } = await mutator.insertOne({
|
const { data: created } = await mutator.insertOne({
|
||||||
...(additional as any),
|
...(additional as any),
|
||||||
|
role: role || this.config.default_role_register || undefined,
|
||||||
email,
|
email,
|
||||||
strategy,
|
strategy,
|
||||||
strategy_value,
|
strategy_value,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
InvalidSchemaError,
|
InvalidSchemaError,
|
||||||
transformObject,
|
transformObject,
|
||||||
mcpTool,
|
mcpTool,
|
||||||
|
$console,
|
||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
|
||||||
@@ -198,7 +199,12 @@ export class AuthController extends Controller {
|
|||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||||
|
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
hono.route(
|
||||||
|
`/${name}`,
|
||||||
|
strategy.getController(this.auth.authenticator, {
|
||||||
|
allow_register: this.auth.config.allow_register,
|
||||||
|
}),
|
||||||
|
);
|
||||||
this.registerStrategyActions(strategy, hono);
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +216,7 @@ export class AuthController extends Controller {
|
|||||||
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||||
|
|
||||||
const getUser = async (params: { id?: string | number; email?: string }) => {
|
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||||
let user: DB["users"] | undefined = undefined;
|
let user: DB["users"] | undefined;
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
const { data } = await this.userRepo.findId(params.id);
|
const { data } = await this.userRepo.findId(params.id);
|
||||||
user = data;
|
user = data;
|
||||||
@@ -225,26 +231,33 @@ export class AuthController extends Controller {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||||
mcp.tool(
|
try {
|
||||||
"auth_user_create",
|
const actions = this.auth.authenticator.strategy("password").getActions();
|
||||||
{
|
if (actions.create) {
|
||||||
description: "Create a new user",
|
const schema = actions.create.schema;
|
||||||
inputSchema: s.object({
|
mcp.tool(
|
||||||
email: s.string({ format: "email" }),
|
"auth_user_create",
|
||||||
password: s.string({ minLength: 8 }),
|
{
|
||||||
role: s
|
description: "Create a new user",
|
||||||
.string({
|
inputSchema: s.object({
|
||||||
enum: roles.length > 0 ? roles : undefined,
|
...schema.properties,
|
||||||
})
|
role: s
|
||||||
.optional(),
|
.string({
|
||||||
}),
|
enum: roles.length > 0 ? roles : undefined,
|
||||||
},
|
})
|
||||||
async (params, c) => {
|
.optional(),
|
||||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||||
|
|
||||||
return c.json(await this.auth.createUser(params));
|
return c.json(await this.auth.createUser(params));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
$console.warn("error creating auth_user_create tool", e);
|
||||||
|
}
|
||||||
|
|
||||||
mcp.tool(
|
mcp.tool(
|
||||||
"auth_user_token",
|
"auth_user_token",
|
||||||
@@ -297,7 +310,9 @@ export class AuthController extends Controller {
|
|||||||
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
||||||
|
|
||||||
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
const controller = pw.getController(this.auth.authenticator);
|
const controller = pw.getController(this.auth.authenticator, {
|
||||||
|
allow_register: this.auth.config.allow_register,
|
||||||
|
});
|
||||||
|
|
||||||
const res = await controller.request(
|
const res = await controller.request(
|
||||||
new Request("https://localhost/login", {
|
new Request("https://localhost/login", {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const authConfigSchema = $object(
|
|||||||
basepath: s.string({ default: "/api/auth" }),
|
basepath: s.string({ default: "/api/auth" }),
|
||||||
entity_name: s.string({ default: "users" }),
|
entity_name: s.string({ default: "users" }),
|
||||||
allow_register: s.boolean({ default: true }).optional(),
|
allow_register: s.boolean({ default: true }).optional(),
|
||||||
|
default_role_register: s.string().optional(),
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
strategies: $record(
|
strategies: $record(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject(
|
|||||||
export const authenticatorConfig = s.object({
|
export const authenticatorConfig = s.object({
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
|
default_role_register: s.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||||
@@ -164,9 +165,13 @@ export class Authenticator<
|
|||||||
if (!("strategy_value" in profile)) {
|
if (!("strategy_value" in profile)) {
|
||||||
throw new InvalidConditionsException("Profile must have a strategy value");
|
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||||
}
|
}
|
||||||
|
if ("role" in profile) {
|
||||||
|
throw new InvalidConditionsException("Role cannot be provided during registration");
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userPool.create(strategy.getName(), {
|
const user = await this.userPool.create(strategy.getName(), {
|
||||||
...profile,
|
...profile,
|
||||||
|
role: this.config.default_role_register,
|
||||||
strategy_value: profile.strategy_value,
|
strategy_value: profile.strategy_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,7 +430,7 @@ export class Authenticator<
|
|||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
if (headers.has("Authorization")) {
|
if (headers.has("Authorization")) {
|
||||||
const bearerHeader = String(headers.get("Authorization"));
|
const bearerHeader = String(headers.get("Authorization"));
|
||||||
token = bearerHeader.replace("Bearer ", "");
|
token = bearerHeader.replace(/^Bearer\s+/i, "");
|
||||||
} else {
|
} else {
|
||||||
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
||||||
token = await this.getAuthCookie(context);
|
token = await this.getAuthCookie(context);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const schema = s
|
|||||||
.object({
|
.object({
|
||||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||||
|
minLength: s.number({ default: 8, minimum: 1 }).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
format: "email",
|
format: "email",
|
||||||
}),
|
}),
|
||||||
password: s.string({
|
password: s.string({
|
||||||
minLength: 8, // @todo: this should be configurable
|
minLength: this.config.minLength,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,19 +66,28 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
return await bcryptCompare(compare, actual);
|
return await bcryptCompare(compare, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return actual === compare;
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(password: string) {
|
verify(password: string) {
|
||||||
return async (user: User) => {
|
return async (user: User) => {
|
||||||
const compare = await this.compare(user?.strategy_value!, password);
|
if (!user || !user.strategy_value) {
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
|
||||||
|
$console.debug("PasswordStrategy: Invalid password", password);
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const compare = await this.compare(user.strategy_value, password);
|
||||||
if (compare !== true) {
|
if (compare !== true) {
|
||||||
throw new InvalidCredentialsException();
|
throw new InvalidCredentialsException();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(authenticator: Authenticator): Hono<any> {
|
getController(authenticator: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
const redirectQuerySchema = s.object({
|
const redirectQuerySchema = s.object({
|
||||||
redirect: s.string().optional(),
|
redirect: s.string().optional(),
|
||||||
@@ -110,41 +120,43 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
hono.post(
|
if (opts.allow_register) {
|
||||||
"/register",
|
hono.post(
|
||||||
describeRoute({
|
"/register",
|
||||||
summary: "Register a new user with email and password",
|
describeRoute({
|
||||||
tags: ["auth"],
|
summary: "Register a new user with email and password",
|
||||||
}),
|
tags: ["auth"],
|
||||||
jsc("query", redirectQuerySchema),
|
}),
|
||||||
async (c) => {
|
jsc("query", redirectQuerySchema),
|
||||||
try {
|
async (c) => {
|
||||||
const { redirect } = c.req.valid("query");
|
try {
|
||||||
const { password, email, ...body } = parse(
|
const { redirect } = c.req.valid("query");
|
||||||
payloadSchema,
|
const { password, email, ...body } = parse(
|
||||||
await authenticator.getBody(c),
|
payloadSchema,
|
||||||
{
|
await authenticator.getBody(c),
|
||||||
onError: (errors) => {
|
{
|
||||||
$console.error("Invalid register payload", [...errors]);
|
onError: (errors) => {
|
||||||
new InvalidCredentialsException();
|
$console.error("Invalid register payload", [...errors]);
|
||||||
|
new InvalidCredentialsException();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
...body,
|
...body,
|
||||||
email,
|
email,
|
||||||
strategy_value: await this.hash(password),
|
strategy_value: await this.hash(password),
|
||||||
};
|
};
|
||||||
|
|
||||||
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||||
redirect,
|
redirect,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return authenticator.respondWithError(c, e as any);
|
return authenticator.respondWithError(c, e as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export abstract class AuthStrategy<Schema extends s.Schema = s.Schema> {
|
|||||||
|
|
||||||
protected abstract getSchema(): Schema;
|
protected abstract getSchema(): Schema;
|
||||||
|
|
||||||
abstract getController(auth: Authenticator): Hono;
|
abstract getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono;
|
||||||
|
|
||||||
getType(): string {
|
getType(): string {
|
||||||
return this.type;
|
return this.type;
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(auth: Authenticator): Hono<any> {
|
getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
const secret = "secret";
|
const secret = "secret";
|
||||||
const cookie_name = "_challenge";
|
const cookie_name = "_challenge";
|
||||||
@@ -379,6 +379,10 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
|
|||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "register" && !opts.allow_register) {
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const path = url.pathname.replace(`/${action}`, "");
|
const path = url.pathname.replace(`/${action}`, "");
|
||||||
const redirect_uri = url.origin + path + "/callback";
|
const redirect_uri = url.origin + path + "/callback";
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { CliCommand } from "cli/types";
|
|
||||||
import { Option } from "commander";
|
|
||||||
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
|
||||||
import * as utils from "./utils";
|
|
||||||
import { $console } from "core/utils";
|
|
||||||
import { Project } from "./lib/Project";
|
|
||||||
|
|
||||||
export const dev: CliCommand = (program) =>
|
|
||||||
withConfigOptions(program.command("dev"))
|
|
||||||
.description("dev server")
|
|
||||||
.addOption(new Option("--build", "build the project"))
|
|
||||||
.action(action);
|
|
||||||
|
|
||||||
async function action(options: WithConfigOptions<{ build?: boolean }>) {
|
|
||||||
console.log("options", options);
|
|
||||||
const project = new Project({
|
|
||||||
templatePath: utils.TEMPLATE_PATH,
|
|
||||||
});
|
|
||||||
|
|
||||||
await project.init();
|
|
||||||
|
|
||||||
if (options.build) {
|
|
||||||
await project.build();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
await project.listen();
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { RelativeFS } from "./RelativeFS";
|
|
||||||
//import { $console } from "bknd/utils";
|
|
||||||
import { type ViteDevServer, createServer, type UserConfig, createBuilder } from "vite";
|
|
||||||
import { readdir, copyFile, stat } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
||||||
import { getRelativeDistPath } from "cli/utils/sys";
|
|
||||||
import { injectMain } from "./vite/inject-main";
|
|
||||||
import { devFsVitePlugin } from "adapter/cloudflare";
|
|
||||||
|
|
||||||
export type ProjectOptions = {
|
|
||||||
userPath?: string;
|
|
||||||
templatePath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @todo: add multiple public dirs
|
|
||||||
// @todo: add npm install (first time)
|
|
||||||
// @todo: add package.json
|
|
||||||
export class Project {
|
|
||||||
public userFs: RelativeFS;
|
|
||||||
public templateFs: RelativeFS;
|
|
||||||
private _server?: ViteDevServer;
|
|
||||||
|
|
||||||
constructor(public options: ProjectOptions) {
|
|
||||||
this.userFs = new RelativeFS(options.userPath ?? process.cwd());
|
|
||||||
this.templateFs = new RelativeFS(options.templatePath ?? "src/cli/commands/dev/template");
|
|
||||||
}
|
|
||||||
|
|
||||||
get server() {
|
|
||||||
if (!this._server) {
|
|
||||||
throw new Error("Server not initialized");
|
|
||||||
}
|
|
||||||
return this._server!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
const source = this.templateFs.root;
|
|
||||||
const destination = this.userFs.root;
|
|
||||||
|
|
||||||
// recursively copy all files and directories from source to dest
|
|
||||||
const copyRecursive = async (src: string, dst: string) => {
|
|
||||||
const entries = await readdir(src, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const srcPath = path.join(src, entry.name);
|
|
||||||
const dstPath = path.join(dst, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await this.userFs.makeDir(path.relative(destination, dstPath));
|
|
||||||
await copyRecursive(srcPath, dstPath);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
const exists = await stat(dstPath)
|
|
||||||
.then((s) => s.isFile())
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
// only copy everything from ".bknd" with override
|
|
||||||
if (!exists || (dstPath.includes(".bknd") && !dstPath.includes("bknd-types.d.ts"))) {
|
|
||||||
await copyFile(srcPath, dstPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await copyRecursive(source, destination);
|
|
||||||
this._server = await createServer(this.getViteConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
private getViteConfig(): UserConfig {
|
|
||||||
return {
|
|
||||||
clearScreen: false,
|
|
||||||
publicDir: getRelativeDistPath() + "/static",
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
devFsVitePlugin({ configFile: ".bknd/bknd.config.ts" }) as any,
|
|
||||||
cloudflare({
|
|
||||||
configPath: this.userFs.path(".bknd/wrangler.json"),
|
|
||||||
persistState: {
|
|
||||||
path: this.userFs.path(".bknd/state"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
injectMain({
|
|
||||||
appPath: "./src/App.tsx",
|
|
||||||
rootId: "root",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
build: {
|
|
||||||
minify: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
dedupe: ["react", "react-dom"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async build() {
|
|
||||||
const builder = await createBuilder(this.getViteConfig());
|
|
||||||
await builder.buildApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
async listen() {
|
|
||||||
await this.server.listen();
|
|
||||||
this.server.printUrls();
|
|
||||||
this.server.bindCLIShortcuts({ print: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import {
|
|
||||||
mkdir,
|
|
||||||
stat,
|
|
||||||
readFile as nodeReadFile,
|
|
||||||
writeFile as nodeWriteFile,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { getRootPath } from "cli/utils/sys";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export class RelativeFS {
|
|
||||||
public root: string;
|
|
||||||
|
|
||||||
constructor(p: string) {
|
|
||||||
this.root = path.resolve(getRootPath(), p);
|
|
||||||
}
|
|
||||||
|
|
||||||
path(p: string) {
|
|
||||||
return path.join(this.root, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasFile(path: string) {
|
|
||||||
try {
|
|
||||||
const s = await stat(this.path(path));
|
|
||||||
return s.isFile();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasDir(path: string) {
|
|
||||||
try {
|
|
||||||
const s = await stat(this.path(path));
|
|
||||||
return s.isDirectory();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readFile(p: string) {
|
|
||||||
return await nodeReadFile(this.path(p), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeFile(p: string, content: string) {
|
|
||||||
return await nodeWriteFile(this.path(p), content);
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeDir(p: string) {
|
|
||||||
return await mkdir(this.path(p), { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { PluginOption } from "vite";
|
|
||||||
|
|
||||||
export function injectMain(options?: { appPath?: string; rootId?: string }): PluginOption {
|
|
||||||
const appPath = options?.appPath ?? "/App.tsx";
|
|
||||||
const rootId = options?.rootId ?? "root";
|
|
||||||
const publicId = "/@virtual/react-main.js";
|
|
||||||
const internalId = "\0virtual-react-main.js";
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "bknd-virtual-react-entry",
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return {
|
|
||||||
html,
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
tag: "script",
|
|
||||||
injectTo: "body",
|
|
||||||
attrs: {
|
|
||||||
type: "module",
|
|
||||||
src: publicId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
resolveId(id) {
|
|
||||||
if (id === publicId) {
|
|
||||||
return internalId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
load(id) {
|
|
||||||
if (id === internalId) {
|
|
||||||
return `
|
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "${appPath}";
|
|
||||||
import { ClientProvider } from "bknd/client";
|
|
||||||
|
|
||||||
const container = document.getElementById("${rootId}");
|
|
||||||
if (!container) {
|
|
||||||
throw new Error("Cannot find #${rootId} element");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(container);
|
|
||||||
root.render(
|
|
||||||
React.createElement(
|
|
||||||
React.StrictMode,
|
|
||||||
null,
|
|
||||||
React.createElement(ClientProvider, null, React.createElement(App, null))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { DB } from "bknd";
|
|
||||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;
|
|
||||||
type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;
|
|
||||||
type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { serve } from "bknd/adapter/cloudflare";
|
|
||||||
import config from "./bknd.config";
|
|
||||||
|
|
||||||
export default serve(config);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bknd-dev",
|
|
||||||
"main": "./worker.ts",
|
|
||||||
"compatibility_date": "2025-10-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
|
||||||
"observability": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"assets": {
|
|
||||||
"binding": "ASSETS",
|
|
||||||
"directory": "../dist/client",
|
|
||||||
"not_found_handling": "single-page-application",
|
|
||||||
"run_worker_first": ["!/", "/admin*", "/api*", "!/assets/*"]
|
|
||||||
},
|
|
||||||
"vars": {
|
|
||||||
"ENVIRONMENT": "development"
|
|
||||||
},
|
|
||||||
"d1_databases": [
|
|
||||||
{
|
|
||||||
"binding": "DB"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "BUCKET"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>bknd + Vite + Cloudflare + React + TS</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import "./styles.css";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <div>Hello World</div>;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import type { ServerEnv } from "bknd";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add custom routes to the API here. Base path is `/api`.
|
|
||||||
*/
|
|
||||||
export default new Hono<ServerEnv>().get("/", (c) => {
|
|
||||||
// const app = c.var.app;
|
|
||||||
// const api = app.getApi();
|
|
||||||
return c.json({ message: "Hello, world!" });
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { AppEvents, DatabaseEvents, type EventManager } from "bknd";
|
|
||||||
|
|
||||||
export default function (emgr: EventManager) {
|
|
||||||
// emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
|
||||||
// console.log("Request received", event.params.request.url);
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { em, entity, text, boolean, number, datetime, json, jsonSchema, enumm } from "bknd";
|
|
||||||
|
|
||||||
export default em({
|
|
||||||
// todos: entity("todos", {
|
|
||||||
// title: text(),
|
|
||||||
// done: boolean(),
|
|
||||||
// }),
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.bknd/tsconfig.json",
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import {
|
|
||||||
mkdir,
|
|
||||||
stat,
|
|
||||||
readFile as nodeReadFile,
|
|
||||||
writeFile as nodeWriteFile,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { getRootPath } from "cli/utils/sys";
|
|
||||||
|
|
||||||
export const TEMPLATE_PATH = "src/cli/commands/dev/template";
|
|
||||||
export const currentDir = process.cwd();
|
|
||||||
|
|
||||||
export const fs = (dir: string) => {
|
|
||||||
const PATH = path.resolve(getRootPath(), dir);
|
|
||||||
return {
|
|
||||||
PATH,
|
|
||||||
path: (_path: string) => path.join(PATH, _path),
|
|
||||||
hasFile: async (file: string) => {
|
|
||||||
try {
|
|
||||||
const s = await stat(path.join(PATH, file));
|
|
||||||
return s.isFile();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hasDir: async (_dir: string) => {
|
|
||||||
try {
|
|
||||||
const s = await stat(path.join(PATH, _dir));
|
|
||||||
return s.isDirectory();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readFile: (file: string) => nodeReadFile(path.join(PATH, file), "utf-8"),
|
|
||||||
readJsonFile: async (file: string) =>
|
|
||||||
JSON.parse(await nodeReadFile(path.join(PATH, file), "utf-8")),
|
|
||||||
writeFile: (file: string, content: string) => nodeWriteFile(path.join(PATH, file), content),
|
|
||||||
makeDir: (_newDir: string) => mkdir(path.join(PATH, _newDir)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -9,4 +9,3 @@ export { types } from "./types";
|
|||||||
export { mcp } from "./mcp/mcp";
|
export { mcp } from "./mcp/mcp";
|
||||||
export { sync } from "./sync";
|
export { sync } from "./sync";
|
||||||
export { secrets } from "./secrets";
|
export { secrets } from "./secrets";
|
||||||
export { dev } from "./dev/dev";
|
|
||||||
@@ -21,46 +21,47 @@ export const sync: CliCommand = (program) => {
|
|||||||
console.info("");
|
console.info("");
|
||||||
if (stmts.length === 0) {
|
if (stmts.length === 0) {
|
||||||
console.info(c.yellow("No changes to sync"));
|
console.info(c.yellow("No changes to sync"));
|
||||||
process.exit(0);
|
} else {
|
||||||
}
|
// @todo: currently assuming parameters aren't used
|
||||||
// @todo: currently assuming parameters aren't used
|
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
||||||
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
|
||||||
|
|
||||||
if (options.force) {
|
if (options.force) {
|
||||||
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
|
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
|
||||||
await schema.sync({ force: true, drop: options.drop });
|
await schema.sync({ force: true, drop: options.drop });
|
||||||
|
|
||||||
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||||
console.info(`${c.green("Database synced")}`);
|
console.info(`${c.green("Database synced")}`);
|
||||||
|
} else {
|
||||||
if (options.seed) {
|
if (options.out) {
|
||||||
console.info(c.dim("\nExecuting seed..."));
|
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||||
const seed = app.options?.seed;
|
await writeFile(options.out, output);
|
||||||
if (seed) {
|
console.info(`SQL written to ${c.cyan(options.out)}`);
|
||||||
await app.options?.seed?.({
|
|
||||||
...app.modules.ctx(),
|
|
||||||
app: app,
|
|
||||||
});
|
|
||||||
console.info(c.green("Seed executed"));
|
|
||||||
} else {
|
} else {
|
||||||
console.info(c.yellow("No seed function provided"));
|
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
c.yellow(
|
||||||
|
"\nNo statements have been executed. Use --force to perform database syncing operations",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if (options.out) {
|
|
||||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
|
||||||
await writeFile(options.out, output);
|
|
||||||
console.info(`SQL written to ${c.cyan(options.out)}`);
|
|
||||||
} else {
|
|
||||||
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
|
|
||||||
|
|
||||||
console.info(
|
if (options.seed) {
|
||||||
c.yellow(
|
console.info(c.dim("\nExecuting seed..."));
|
||||||
"\nNo statements have been executed. Use --force to perform database syncing operations",
|
const seed = app.options?.seed;
|
||||||
),
|
if (seed) {
|
||||||
);
|
await seed({
|
||||||
|
...app.modules.ctx(),
|
||||||
|
app: app,
|
||||||
|
});
|
||||||
|
console.info(c.green("Seed executed"));
|
||||||
|
} else {
|
||||||
|
console.info(c.yellow("No seed function provided"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import color from "picocolors";
|
import color from "picocolors";
|
||||||
import * as commands from "./commands";
|
import * as commands from "./commands";
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
|
|||||||
try {
|
try {
|
||||||
// @ts-expect-error - this is a global variable in dev
|
// @ts-expect-error - this is a global variable in dev
|
||||||
return is_toggled(__isDev);
|
return is_toggled(__isDev);
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,10 @@ export class Entity<
|
|||||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
|
||||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
||||||
|
field.isFillable(context),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRequiredFields(): Field[] {
|
getRequiredFields(): Field[] {
|
||||||
@@ -189,9 +191,15 @@ export class Entity<
|
|||||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFields(include_virtual: boolean = false): Field[] {
|
getFields({
|
||||||
if (include_virtual) return this.fields;
|
virtual = false,
|
||||||
return this.fields.filter((f) => !f.isVirtual());
|
primary = true,
|
||||||
|
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
|
||||||
|
return this.fields.filter((f) => {
|
||||||
|
if (!virtual && f.isVirtual()) return false;
|
||||||
|
if (!primary && f instanceof PrimaryField) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addField(field: Field) {
|
addField(field: Field) {
|
||||||
@@ -231,7 +239,7 @@ export class Entity<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = this.getFillableFields(context, false);
|
const fields = this.getFillableFields(context as any, false);
|
||||||
|
|
||||||
if (options?.ignoreUnknown !== true) {
|
if (options?.ignoreUnknown !== true) {
|
||||||
const field_names = fields.map((f) => f.name);
|
const field_names = fields.map((f) => f.name);
|
||||||
@@ -275,7 +283,7 @@ export class Entity<
|
|||||||
fields = this.getFillableFields(options.context);
|
fields = this.getFillableFields(options.context);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fields = this.getFields(true);
|
fields = this.getFields({ virtual: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ export class Mutator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// we should never get here, but just to be sure (why?)
|
// we should never get here, but just to be sure (why?)
|
||||||
if (!field.isFillable(context)) {
|
if (!field.isFillable(context as any)) {
|
||||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
throw new Error(
|
||||||
|
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// transform from field
|
// transform from field
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class WithBuilder {
|
|||||||
if (query) {
|
if (query) {
|
||||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||||
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
||||||
|
alias: other.reference,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
label: s.string(),
|
label: s.string(),
|
||||||
description: s.string(),
|
description: s.string(),
|
||||||
required: s.boolean({ default: false }),
|
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
||||||
fillable: s.anyOf([
|
fillable: s.anyOf(
|
||||||
s.boolean({ title: "Boolean" }),
|
[
|
||||||
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
s.boolean({ title: "Boolean" }),
|
||||||
]),
|
s.array(s.string({ enum: ["create", "update"] }), {
|
||||||
|
title: "Context",
|
||||||
|
uniqueItems: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
default: DEFAULT_FILLABLE,
|
||||||
|
},
|
||||||
|
),
|
||||||
hidden: s.anyOf([
|
hidden: s.anyOf([
|
||||||
s.boolean({ title: "Boolean" }),
|
s.boolean({ title: "Boolean" }),
|
||||||
// @todo: tmp workaround
|
// @todo: tmp workaround
|
||||||
@@ -103,7 +111,7 @@ export abstract class Field<
|
|||||||
return this.config?.default_value;
|
return this.config?.default_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFillable(context?: TActionContext): boolean {
|
isFillable(context?: "create" | "update"): boolean {
|
||||||
if (Array.isArray(this.config.fillable)) {
|
if (Array.isArray(this.config.fillable)) {
|
||||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||||
}
|
}
|
||||||
@@ -165,7 +173,7 @@ export abstract class Field<
|
|||||||
// @todo: add field level validation
|
// @todo: add field level validation
|
||||||
isValid(value: any, context: TActionContext): boolean {
|
isValid(value: any, context: TActionContext): boolean {
|
||||||
if (typeof value !== "undefined") {
|
if (typeof value !== "undefined") {
|
||||||
return this.isFillable(context);
|
return this.isFillable(context as any);
|
||||||
} else if (context === "create") {
|
} else if (context === "create") {
|
||||||
return !this.isRequired();
|
return !this.isRequired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function fieldTestSuite(
|
|||||||
const _config = {
|
const _config = {
|
||||||
..._requiredConfig,
|
..._requiredConfig,
|
||||||
required: false,
|
required: false,
|
||||||
|
fillable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function fieldJson(field: Field) {
|
function fieldJson(field: Field) {
|
||||||
@@ -116,10 +117,7 @@ export function fieldTestSuite(
|
|||||||
|
|
||||||
expect(fieldJson(fillable)).toEqual({
|
expect(fieldJson(fillable)).toEqual({
|
||||||
type: noConfigField.type,
|
type: noConfigField.type,
|
||||||
config: {
|
config: _config,
|
||||||
..._config,
|
|
||||||
fillable: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fieldJson(required)).toEqual({
|
expect(fieldJson(required)).toEqual({
|
||||||
@@ -150,7 +148,6 @@ export function fieldTestSuite(
|
|||||||
type: requiredAndDefault.type,
|
type: requiredAndDefault.type,
|
||||||
config: {
|
config: {
|
||||||
..._config,
|
..._config,
|
||||||
fillable: true,
|
|
||||||
required: true,
|
required: true,
|
||||||
default_value: config.defaultValue,
|
default_value: config.defaultValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function getChangeSet(
|
|||||||
(acc, _value, key) => {
|
(acc, _value, key) => {
|
||||||
const field = fields.find((f) => f.name === key);
|
const field = fields.find((f) => f.name === key);
|
||||||
// @todo: filtering virtual here, need to check (because of media)
|
// @todo: filtering virtual here, need to check (because of media)
|
||||||
if (!field || field.isVirtual()) return;
|
// @note: adding non-fillable here as it'll fail validaiton on backend
|
||||||
|
if (!field || field.isVirtual() || !field.isFillable(action as "create" | "update")) return;
|
||||||
const value = _value === "" ? null : _value;
|
const value = _value === "" ? null : _value;
|
||||||
|
|
||||||
// normalize to null if undefined
|
// normalize to null if undefined
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class SchemaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||||
const fields = entity.getFields(false);
|
const fields = entity.getFields({ virtual: false });
|
||||||
const indices = this.em.getIndicesOf(entity);
|
const indices = this.em.getIndicesOf(entity);
|
||||||
|
|
||||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||||
|
|||||||
@@ -71,11 +71,12 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected uploadFile<T extends FileUploadedEventData>(
|
protected uploadFile<T extends FileUploadedEventData>(
|
||||||
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
body: BodyInit,
|
||||||
opts?: {
|
opts?: {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
path?: TInput;
|
path?: TInput;
|
||||||
_init?: Omit<RequestInit, "body">;
|
_init?: Omit<RequestInit, "body">;
|
||||||
|
query?: Record<string, any>;
|
||||||
},
|
},
|
||||||
): FetchPromise<ResponseObject<T>> {
|
): FetchPromise<ResponseObject<T>> {
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -102,14 +103,22 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
if (opts?.path) {
|
if (opts?.path) {
|
||||||
return this.post(opts.path, body, init);
|
return this.request<T>(opts.path, opts?.query, {
|
||||||
|
...init,
|
||||||
|
body,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || name.length === 0) {
|
if (!name || name.length === 0) {
|
||||||
throw new Error("Invalid filename");
|
throw new Error("Invalid filename");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.post<T>(opts?.path ?? ["upload", name], body, init);
|
return this.request<T>(opts?.path ?? ["upload", name], opts?.query, {
|
||||||
|
...init,
|
||||||
|
body,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload<T extends FileUploadedEventData>(
|
async upload<T extends FileUploadedEventData>(
|
||||||
@@ -119,6 +128,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
_init?: Omit<RequestInit, "body">;
|
_init?: Omit<RequestInit, "body">;
|
||||||
path?: TInput;
|
path?: TInput;
|
||||||
fetcher?: ApiFetcher;
|
fetcher?: ApiFetcher;
|
||||||
|
query?: Record<string, any>;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (item instanceof Request || typeof item === "string") {
|
if (item instanceof Request || typeof item === "string") {
|
||||||
@@ -144,7 +154,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.uploadFile<T>(item, opts);
|
return this.uploadFile<T>(item as BodyInit, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadToEntity(
|
async uploadToEntity(
|
||||||
@@ -155,11 +165,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
opts?: {
|
opts?: {
|
||||||
_init?: Omit<RequestInit, "body">;
|
_init?: Omit<RequestInit, "body">;
|
||||||
fetcher?: typeof fetch;
|
fetcher?: typeof fetch;
|
||||||
|
overwrite?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
|
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
|
||||||
|
const query = opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : undefined;
|
||||||
return this.upload(item, {
|
return this.upload(item, {
|
||||||
...opts,
|
...opts,
|
||||||
path: ["entity", entity, id, field],
|
path: ["entity", entity, id, field],
|
||||||
|
query,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { transformObject } from "bknd/utils";
|
import { transformObject } from "bknd/utils";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import { set } from "lodash-es";
|
import { set } from "lodash-es";
|
||||||
|
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
||||||
|
|
||||||
export type MigrationContext = {
|
export type MigrationContext = {
|
||||||
db: Kysely<any>;
|
db: Kysely<any>;
|
||||||
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
|
|||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// change field.config.fillable to only "create" and "update"
|
||||||
|
version: 11,
|
||||||
|
up: async (config: InitialModuleConfigs) => {
|
||||||
|
const { data, ...rest } = config;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
entities: transformObject(data?.entities ?? {}, (entity) => {
|
||||||
|
return {
|
||||||
|
...entity,
|
||||||
|
fields: transformObject(entity?.fields ?? {}, (field) => {
|
||||||
|
const fillable = field!.config?.fillable;
|
||||||
|
if (!fillable || typeof fillable === "boolean") return field;
|
||||||
|
return { ...field, config: { ...field!.config, fillable: true } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
|
|||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { TApiUser } from "Api";
|
import type { TApiUser } from "Api";
|
||||||
|
import type { AppTheme } from "ui/client/use-theme";
|
||||||
import type { Manifest } from "vite";
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||||
@@ -20,7 +21,7 @@ export type AdminBkndWindowContext = {
|
|||||||
logout_route: string;
|
logout_route: string;
|
||||||
admin_basepath: string;
|
admin_basepath: string;
|
||||||
logo_return_path?: string;
|
logo_return_path?: string;
|
||||||
theme?: "dark" | "light" | "system";
|
theme?: AppTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @todo: add migration to remove admin path from config
|
// @todo: add migration to remove admin path from config
|
||||||
@@ -31,7 +32,7 @@ export type AdminControllerOptions = {
|
|||||||
html?: string;
|
html?: string;
|
||||||
forceDev?: boolean | { mainPath: string };
|
forceDev?: boolean | { mainPath: string };
|
||||||
debugRerenders?: boolean;
|
debugRerenders?: boolean;
|
||||||
theme?: "dark" | "light" | "system";
|
theme?: AppTheme;
|
||||||
logoReturnPath?: string;
|
logoReturnPath?: string;
|
||||||
manifest?: Manifest;
|
manifest?: Manifest;
|
||||||
};
|
};
|
||||||
@@ -122,7 +123,7 @@ export class AdminController extends Controller {
|
|||||||
const obj: AdminBkndWindowContext = {
|
const obj: AdminBkndWindowContext = {
|
||||||
user: c.get("auth")?.user,
|
user: c.get("auth")?.user,
|
||||||
logout_route: authRoutes.logout,
|
logout_route: authRoutes.logout,
|
||||||
admin_basepath: this.options.adminBasepath,
|
admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""),
|
||||||
theme: this.options.theme,
|
theme: this.options.theme,
|
||||||
logo_return_path: this.options.logoReturnPath,
|
logo_return_path: this.options.logoReturnPath,
|
||||||
};
|
};
|
||||||
@@ -209,8 +210,9 @@ export class AdminController extends Controller {
|
|||||||
},
|
},
|
||||||
}).then((res) => res.json());
|
}).then((res) => res.json());
|
||||||
} else {
|
} else {
|
||||||
|
const manifestPath = "bknd/dist/manifest.json";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
manifest = await import("bknd/dist/manifest.json", {
|
manifest = await import(/* @vite-ignore */ manifestPath, {
|
||||||
with: { type: "json" },
|
with: { type: "json" },
|
||||||
}).then((res) => res.default);
|
}).then((res) => res.default);
|
||||||
}
|
}
|
||||||
@@ -304,7 +306,7 @@ const wrapperStyle = css`
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color: rgb(9,9,11);
|
color: rgb(9,9,11);
|
||||||
background-color: rgb(250,250,250);
|
background-color: rgb(250,250,250);
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color: rgb(250,250,250);
|
color: rgb(250,250,250);
|
||||||
background-color: rgb(30,31,34);
|
background-color: rgb(30,31,34);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function emailOTP({
|
|||||||
"/login",
|
"/login",
|
||||||
jsc(
|
jsc(
|
||||||
"json",
|
"json",
|
||||||
s.object({
|
s.strictObject({
|
||||||
email: s.string({ format: "email" }),
|
email: s.string({ format: "email" }),
|
||||||
code: s.string({ minLength: 1 }).optional(),
|
code: s.string({ minLength: 1 }).optional(),
|
||||||
}),
|
}),
|
||||||
@@ -213,7 +213,7 @@ export function emailOTP({
|
|||||||
),
|
),
|
||||||
jsc("query", s.object({ redirect: s.string().optional() })),
|
jsc("query", s.object({ redirect: s.string().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { email, code } = c.req.valid("json");
|
const { email, code, ...rest } = c.req.valid("json");
|
||||||
const { redirect } = c.req.valid("query");
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
// throw if user exists
|
// throw if user exists
|
||||||
@@ -232,6 +232,7 @@ export function emailOTP({
|
|||||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
||||||
|
|
||||||
const user = await app.createUser({
|
const user = await app.createUser({
|
||||||
|
...rest,
|
||||||
email,
|
email,
|
||||||
password: randomString(32, true),
|
password: randomString(32, true),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,858 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { sort } from "./sort.plugin";
|
||||||
|
import { em, entity, text, number } from "bknd";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("sort plugin", () => {
|
||||||
|
test("should add sort field to configured entities", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const taskEntity = app.em.entity("tasks");
|
||||||
|
expect(taskEntity).toBeDefined();
|
||||||
|
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||||
|
expect(taskEntity?.field("position")?.type).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should auto-assign sort values on insert (starting from 0)", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// insert first item
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
expect(task1.position).toBe(0);
|
||||||
|
|
||||||
|
// insert second item
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
expect(task2.position).toBe(1);
|
||||||
|
|
||||||
|
// insert third item
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
expect(task3.position).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve manually set sort values on insert", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// insert with explicit position
|
||||||
|
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
|
||||||
|
expect(task.position).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reorder items via API endpoint", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
|
||||||
|
// move task3 (position 2) to position 0
|
||||||
|
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: task3.id, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
|
||||||
|
expect(updatedTask3.position).toBe(0); // moved to position 0
|
||||||
|
expect(updatedTask1.position).toBe(1); // shifted down
|
||||||
|
expect(updatedTask2.position).toBe(2); // shifted down
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should automatically reorder when updating sort field directly", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task4 (position 3) to position 1 by updating directly
|
||||||
|
await mutator.updateOne(task4.id, { position: 1 });
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(0); // unchanged
|
||||||
|
expect(updatedTask2.position).toBe(2); // shifted down
|
||||||
|
expect(updatedTask3.position).toBe(3); // shifted down
|
||||||
|
expect(updatedTask4.position).toBe(1); // moved to position 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should automatically reorder when moving items up", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task1 (position 0) to position 2 by updating directly
|
||||||
|
await mutator.updateOne(task1.id, { position: 2 });
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(2); // moved to position 2
|
||||||
|
expect(updatedTask2.position).toBe(0); // shifted up
|
||||||
|
expect(updatedTask3.position).toBe(1); // shifted up
|
||||||
|
expect(updatedTask4.position).toBe(3); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support scoped sorting", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1
|
||||||
|
const { data: p1t1 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 1",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t2 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 2",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create tasks in project 2
|
||||||
|
const { data: p2t1 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 1",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
const { data: p2t2 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 2",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// positions should be scoped per project
|
||||||
|
expect(p1t1.position).toBe(0);
|
||||||
|
expect(p1t2.position).toBe(1);
|
||||||
|
expect(p2t1.position).toBe(0); // resets for new scope
|
||||||
|
expect(p2t2.position).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reorder only within scope", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1
|
||||||
|
const { data: p1t1 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 1",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t2 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 2",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t3 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 3",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create tasks in project 2
|
||||||
|
const { data: p2t1 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 1",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
const { data: p2t2 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 2",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// move p1t3 to position 0 (should only affect project 1)
|
||||||
|
await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: p1t3.id, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify project 1 tasks
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
|
||||||
|
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
|
||||||
|
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
|
||||||
|
|
||||||
|
expect(updatedP1t3.position).toBe(0); // moved to position 0
|
||||||
|
expect(updatedP1t1.position).toBe(1); // shifted
|
||||||
|
expect(updatedP1t2.position).toBe(2); // shifted
|
||||||
|
|
||||||
|
// verify project 2 tasks are unchanged
|
||||||
|
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
|
||||||
|
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
|
||||||
|
|
||||||
|
expect(updatedP2t1.position).toBe(0); // unchanged
|
||||||
|
expect(updatedP2t2.position).toBe(1); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should recalculate all positions", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks with irregular positions
|
||||||
|
await mutator.insertOne({ title: "Task 1", position: 5 });
|
||||||
|
await mutator.insertOne({ title: "Task 2", position: 10 });
|
||||||
|
await mutator.insertOne({ title: "Task 3", position: 15 });
|
||||||
|
await mutator.insertOne({ title: "Task 4", position: 100 });
|
||||||
|
|
||||||
|
// recalculate
|
||||||
|
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify positions are now 0, 1, 2, 3
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(4);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
expect(tasks[2].position).toBe(2);
|
||||||
|
expect(tasks[3].position).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should recalculate positions within scope only", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1 with irregular positions
|
||||||
|
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
|
||||||
|
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
|
||||||
|
|
||||||
|
// create tasks in project 2 with irregular positions
|
||||||
|
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
|
||||||
|
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
|
||||||
|
|
||||||
|
// recalculate only project 1
|
||||||
|
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ scope: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify project 1 tasks are recalculated
|
||||||
|
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
where: { project_id: 1 },
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1Tasks.length).toBe(2);
|
||||||
|
expect(p1Tasks[0].position).toBe(0);
|
||||||
|
expect(p1Tasks[1].position).toBe(1);
|
||||||
|
|
||||||
|
// verify project 2 tasks are unchanged
|
||||||
|
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
where: { project_id: 2 },
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p2Tasks.length).toBe(2);
|
||||||
|
expect(p2Tasks[0].position).toBe(10); // unchanged
|
||||||
|
expect(p2Tasks[1].position).toBe(20); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle moving items to the end", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task1 to the end (position 3)
|
||||||
|
await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: task1.id, position: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(3); // moved to end
|
||||||
|
expect(updatedTask2.position).toBe(0); // shifted up
|
||||||
|
expect(updatedTask3.position).toBe(1); // shifted up
|
||||||
|
expect(updatedTask4.position).toBe(2); // shifted up
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid item id", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: 999999, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple entities with different configurations", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
categories: entity("categories", {
|
||||||
|
name: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
field: "order",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
// verify both entities have their sort fields
|
||||||
|
const taskEntity = app.em.entity("tasks");
|
||||||
|
const categoryEntity = app.em.entity("categories");
|
||||||
|
|
||||||
|
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||||
|
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
|
||||||
|
|
||||||
|
// create items in both entities
|
||||||
|
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
|
||||||
|
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
|
||||||
|
|
||||||
|
expect(task.position).toBe(0);
|
||||||
|
expect(category.order).toBe(0);
|
||||||
|
|
||||||
|
// verify both endpoints exist
|
||||||
|
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const catRes = await app.server.request("/api/sort/categories/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(taskRes.status).toBe(200);
|
||||||
|
expect(catRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not trigger reorder when updating other fields", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
|
||||||
|
// update title only (should not trigger reordering)
|
||||||
|
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
|
||||||
|
|
||||||
|
// verify positions are unchanged
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(0);
|
||||||
|
expect(updatedTask2.position).toBe(1);
|
||||||
|
expect(updatedTask2.title).toBe("Task 2 Updated");
|
||||||
|
expect(updatedTask3.position).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle null sort values", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create task with null position (bypass listener by using kysely directly)
|
||||||
|
await app.connection.kysely
|
||||||
|
.insertInto("tasks")
|
||||||
|
.values({ title: "Task with null", position: null })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// create normal task
|
||||||
|
await mutator.insertOne({ title: "Task 2" });
|
||||||
|
|
||||||
|
// update the null task to have a position
|
||||||
|
const nullTask = await app.connection.kysely
|
||||||
|
.selectFrom("tasks")
|
||||||
|
.selectAll()
|
||||||
|
.where("title", "=", "Task with null")
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
await mutator.updateOne(nullTask!.id, { position: 0 });
|
||||||
|
|
||||||
|
// verify both tasks have proper positions
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(2);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not create duplicates when moving to an occupied position", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create two tasks at positions 0 and 1
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
|
||||||
|
expect(task1.position).toBe(0);
|
||||||
|
expect(task2.position).toBe(1);
|
||||||
|
|
||||||
|
// move task2 (at position 1) to position 0
|
||||||
|
await mutator.updateOne(task2.id, { position: 0 });
|
||||||
|
|
||||||
|
// verify no duplicates
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(2);
|
||||||
|
expect(tasks[0].id).toBe(task2.id);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].id).toBe(task1.id);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
|
||||||
|
// verify no tasks have the same position
|
||||||
|
const positions = tasks.map((t) => t.position);
|
||||||
|
const uniquePositions = new Set(positions);
|
||||||
|
expect(uniquePositions.size).toBe(positions.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve order when recalculating", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks with specific positions
|
||||||
|
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
|
||||||
|
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
|
||||||
|
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
|
||||||
|
|
||||||
|
// recalculate
|
||||||
|
await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify order is preserved (B, A, C based on original positions)
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
|
||||||
|
expect(tasks[2].position).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
|
||||||
|
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
|
||||||
|
const DEFAULT_BATCH_SIZE = 1000;
|
||||||
|
|
||||||
|
export type SortPluginOptions = {
|
||||||
|
/**
|
||||||
|
* The base path for the API endpoints.
|
||||||
|
* @default "/api/sort"
|
||||||
|
*/
|
||||||
|
apiBasePath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for entities that should have sorting enabled.
|
||||||
|
* Key is the entity name, value is the configuration.
|
||||||
|
*/
|
||||||
|
entities: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name of the sort property (must be a number field).
|
||||||
|
*/
|
||||||
|
field: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional scope field name. If provided, sorting will only happen within the same scope.
|
||||||
|
* For example, if scope is "category_id", items will only be sorted within items
|
||||||
|
* that have the same category_id value.
|
||||||
|
*/
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batch size for recalculating sort order.
|
||||||
|
* @default 1000
|
||||||
|
*/
|
||||||
|
recalculateBatchSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SortError extends Exception {
|
||||||
|
override name = "SortError";
|
||||||
|
override code = HttpStatus.BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sort({
|
||||||
|
apiBasePath = "/api/sort",
|
||||||
|
entities,
|
||||||
|
recalculateBatchSize = DEFAULT_BATCH_SIZE,
|
||||||
|
}: SortPluginOptions): AppPlugin {
|
||||||
|
return (app: App) => {
|
||||||
|
return {
|
||||||
|
name: "sort",
|
||||||
|
schema: () => {
|
||||||
|
return em(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(entities).map(([entityName, config]) => [
|
||||||
|
entityName,
|
||||||
|
entity(entityName, {
|
||||||
|
[config.field]: number({ default_value: 0 }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
({ index }, schema) => {
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
const indexed = app.em.getIndexedFields(entityName);
|
||||||
|
if (!indexed.some((f) => f.name === config.field)) {
|
||||||
|
index(schema[entityName]!).on([config.field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onBuilt: async () => {
|
||||||
|
invariant(
|
||||||
|
entities && Object.keys(entities).length > 0,
|
||||||
|
"At least one entity must be configured",
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate entities exist and have the configured fields
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
const entity = app.em.entity(entityName);
|
||||||
|
invariant(entity, `Entity "${entityName}" not found in schema`);
|
||||||
|
|
||||||
|
const sortFieldSchema = entity.field(config.field)!;
|
||||||
|
invariant(
|
||||||
|
sortFieldSchema,
|
||||||
|
`Sort field "${config.field}" not found in entity "${entityName}"`,
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
sortFieldSchema.type === "number",
|
||||||
|
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.scope) {
|
||||||
|
const scopeFieldSchema = entity.field(config.scope);
|
||||||
|
invariant(
|
||||||
|
scopeFieldSchema,
|
||||||
|
`Scope field "${config.scope}" not found in entity "${entityName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hono = new Hono();
|
||||||
|
|
||||||
|
// register recalculate endpoints for each entity
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
hono.post(
|
||||||
|
`/${entityName}/recalculate`,
|
||||||
|
jsc(
|
||||||
|
"json",
|
||||||
|
s
|
||||||
|
.object({
|
||||||
|
scope: s.any().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const scope = body?.scope;
|
||||||
|
|
||||||
|
await recalculateSortOrder(
|
||||||
|
app,
|
||||||
|
entityName,
|
||||||
|
config,
|
||||||
|
recalculateBatchSize,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ success: true, message: "Sort order recalculated" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
hono.post(
|
||||||
|
`/${entityName}/reorder`,
|
||||||
|
jsc(
|
||||||
|
"json",
|
||||||
|
s.object({
|
||||||
|
id: s.any(),
|
||||||
|
position: s.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { id, position } = c.req.valid("json");
|
||||||
|
|
||||||
|
await reorderItem(app, entityName, config, id, position);
|
||||||
|
|
||||||
|
return c.json({ success: true, message: "Item reordered" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.server.route(apiBasePath, hono);
|
||||||
|
|
||||||
|
// register listeners for automatic reordering
|
||||||
|
registerListeners(app, entities);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderItem(
|
||||||
|
app: App,
|
||||||
|
entityName: string,
|
||||||
|
config: SortPluginOptions["entities"][string],
|
||||||
|
id: any,
|
||||||
|
newPosition: number,
|
||||||
|
) {
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const em = app.em.fork();
|
||||||
|
const kysely = em.connection.kysely;
|
||||||
|
|
||||||
|
// get the item
|
||||||
|
const { data: item } = await em.repo(entityName).findOne({ id });
|
||||||
|
if (!item) {
|
||||||
|
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPosition = item[sortField] as number | null | undefined;
|
||||||
|
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||||
|
|
||||||
|
// update the item with new position (using forked em, so no listeners are triggered)
|
||||||
|
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
|
||||||
|
|
||||||
|
// shift other items using kysely
|
||||||
|
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
|
||||||
|
if (newPosition < oldPosition) {
|
||||||
|
// moving up: increment items between newPosition and oldPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where(sortField as any, "<", oldPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
} else {
|
||||||
|
// moving down: decrement items between oldPosition and newPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||||
|
.where(sortField as any, ">", oldPosition)
|
||||||
|
.where(sortField as any, "<=", newPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
} else if (oldPosition === undefined || oldPosition === null) {
|
||||||
|
// new item, shift everything at or after this position
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateSortOrder(
|
||||||
|
app: App,
|
||||||
|
entityName: string,
|
||||||
|
config: SortPluginOptions["entities"][string],
|
||||||
|
batchSize: number,
|
||||||
|
scope?: any,
|
||||||
|
) {
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const db = app.connection.kysely;
|
||||||
|
|
||||||
|
const { count } = (await db
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select((eb) => eb.fn.count<number>("id").as("count"))
|
||||||
|
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scope),
|
||||||
|
)
|
||||||
|
.$castTo<{ count: number }>()
|
||||||
|
.executeTakeFirst()) ?? { count: 0 };
|
||||||
|
|
||||||
|
const batches = Math.ceil(count / batchSize);
|
||||||
|
for (let i = 0; i < batches; i++) {
|
||||||
|
// get all items in scope, ordered by current sort value
|
||||||
|
const items = await db
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select(["id", sortField])
|
||||||
|
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scope),
|
||||||
|
)
|
||||||
|
.orderBy(sortField, "asc")
|
||||||
|
.limit(batchSize)
|
||||||
|
.offset(i * batchSize)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const newQbs = items.map((item, index) =>
|
||||||
|
db
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: index })
|
||||||
|
.where("id", "=", item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.connection.executeQueries(...newQbs);
|
||||||
|
$console.log(
|
||||||
|
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
|
||||||
|
const kysely = app.connection.kysely;
|
||||||
|
|
||||||
|
// handle insert events
|
||||||
|
app.emgr.onEvent(
|
||||||
|
DatabaseEvents.MutatorInsertBefore,
|
||||||
|
async (e) => {
|
||||||
|
const entityName = e.params.entity.name;
|
||||||
|
const config = entities[entityName];
|
||||||
|
if (!config) return e.params.data;
|
||||||
|
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const data = e.params.data;
|
||||||
|
const scopeValue = scopeField ? data[scopeField] : undefined;
|
||||||
|
|
||||||
|
// if no position provided, set to max + 1
|
||||||
|
if (data[sortField] === undefined || data[sortField] === null) {
|
||||||
|
const query = kysely
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
|
||||||
|
// add scope filter if needed
|
||||||
|
.$if(Boolean(scopeField && scopeValue), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scopeValue as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await query.executeTakeFirst();
|
||||||
|
const max = result?.max ?? -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[sortField]: max + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// if position is provided, shift other items at or after that position
|
||||||
|
const newPosition = data[sortField] as number;
|
||||||
|
|
||||||
|
let shiftQuery = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await shiftQuery.execute();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "sync",
|
||||||
|
id: "bknd-sort-insert",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle update events
|
||||||
|
app.emgr.onEvent(
|
||||||
|
DatabaseEvents.MutatorUpdateBefore,
|
||||||
|
async (e) => {
|
||||||
|
const entityName = e.params.entity.name;
|
||||||
|
const config = entities[entityName];
|
||||||
|
if (!config) return e.params.data;
|
||||||
|
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const data = e.params.data;
|
||||||
|
|
||||||
|
// only handle if sort field is being updated
|
||||||
|
if (!(sortField in data)) return e.params.data;
|
||||||
|
|
||||||
|
const newPosition = data[sortField] as number;
|
||||||
|
const id = e.params.entityId;
|
||||||
|
|
||||||
|
// get the current item to know its old position and scope
|
||||||
|
const item = await kysely
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.selectAll()
|
||||||
|
.where("id" as any, "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!item) return data;
|
||||||
|
|
||||||
|
const oldPosition = item[sortField] as number | null | undefined;
|
||||||
|
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||||
|
|
||||||
|
// if oldPosition is null or undefined, treat as inserting at newPosition
|
||||||
|
if (oldPosition === null || oldPosition === undefined) {
|
||||||
|
// shift items at or after the new position
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shift other items using kysely
|
||||||
|
if (oldPosition !== newPosition) {
|
||||||
|
if (newPosition < oldPosition) {
|
||||||
|
// moving up: increment items between newPosition and oldPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where(sortField as any, "<", oldPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
} else {
|
||||||
|
// moving down: decrement items between oldPosition and newPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||||
|
.where(sortField as any, ">", oldPosition)
|
||||||
|
.where(sortField as any, "<=", newPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "sync",
|
||||||
|
id: "bknd-sort-update",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,4 +71,54 @@ describe("timestamps plugin", () => {
|
|||||||
expect(updatedData.updated_at).toBeDefined();
|
expect(updatedData.updated_at).toBeDefined();
|
||||||
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("index strategy", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
posts: entity("posts", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [timestamps({ entities: ["posts"] })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
expect(app.em.indices.length).toBe(0);
|
||||||
|
{
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
posts: entity("posts", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
expect(app.em.indices.length).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
posts: entity("posts", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
expect(app.em.indices.length).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ import { $console } from "bknd/utils";
|
|||||||
export type TimestampsPluginOptions = {
|
export type TimestampsPluginOptions = {
|
||||||
entities: string[];
|
entities: string[];
|
||||||
setUpdatedOnCreate?: boolean;
|
setUpdatedOnCreate?: boolean;
|
||||||
|
indexStrategy?: "composite" | "individual";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ export type TimestampsPluginOptions = {
|
|||||||
export function timestamps({
|
export function timestamps({
|
||||||
entities = [],
|
entities = [],
|
||||||
setUpdatedOnCreate = true,
|
setUpdatedOnCreate = true,
|
||||||
|
indexStrategy,
|
||||||
}: TimestampsPluginOptions): AppPlugin {
|
}: TimestampsPluginOptions): AppPlugin {
|
||||||
return (app: App) => ({
|
return (app: App) => ({
|
||||||
name: "timestamps",
|
name: "timestamps",
|
||||||
@@ -29,19 +31,35 @@ export function timestamps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appEntities = app.em.entities.map((e) => e.name);
|
const appEntities = app.em.entities.map((e) => e.name);
|
||||||
|
const actualEntities = entities.filter((e) => appEntities.includes(e));
|
||||||
|
|
||||||
return em(
|
return em(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
entities
|
actualEntities.map((e) => [
|
||||||
.filter((e) => appEntities.includes(e))
|
e,
|
||||||
.map((e) => [
|
entity(e, {
|
||||||
e,
|
created_at: datetime(),
|
||||||
entity(e, {
|
updated_at: datetime(),
|
||||||
created_at: datetime(),
|
}),
|
||||||
updated_at: datetime(),
|
]),
|
||||||
}),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
|
(fns, schema) => {
|
||||||
|
if (indexStrategy) {
|
||||||
|
for (const entity of actualEntities) {
|
||||||
|
if (entity in schema) {
|
||||||
|
switch (indexStrategy) {
|
||||||
|
case "composite":
|
||||||
|
fns.index(schema[entity]!).on(["created_at", "updated_at"]);
|
||||||
|
break;
|
||||||
|
case "individual":
|
||||||
|
fns.index(schema[entity]!).on(["created_at"]);
|
||||||
|
fns.index(schema[entity]!).on(["updated_at"]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onBuilt: async () => {
|
onBuilt: async () => {
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
|||||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||||
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
||||||
|
export { sort, type SortPluginOptions } from "./data/sort.plugin";
|
||||||
@@ -16,6 +16,11 @@ export type BkndAdminConfig = {
|
|||||||
* @default `/`
|
* @default `/`
|
||||||
*/
|
*/
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
|
/**
|
||||||
|
* Sub-path for the Admin UI within the base path
|
||||||
|
* @default ``
|
||||||
|
*/
|
||||||
|
admin_basepath?: string;
|
||||||
/**
|
/**
|
||||||
* Path to return to when clicking the logo
|
* Path to return to when clicking the logo
|
||||||
* @default `/`
|
* @default `/`
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
|
|||||||
export function useBkndOptions(): BkndAdminProps["config"] {
|
export function useBkndOptions(): BkndAdminProps["config"] {
|
||||||
const ctx = useContext(BkndContext);
|
const ctx = useContext(BkndContext);
|
||||||
return (
|
return (
|
||||||
ctx.options ?? {
|
ctx?.options ?? {
|
||||||
basepath: "/",
|
basepath: "/",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export class AppReduced {
|
|||||||
protected appJson: AppType,
|
protected appJson: AppType,
|
||||||
protected _options: BkndAdminProps["config"] = {},
|
protected _options: BkndAdminProps["config"] = {},
|
||||||
) {
|
) {
|
||||||
//console.log("received appjson", appJson);
|
|
||||||
|
|
||||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||||
return constructEntity(name, entity);
|
return constructEntity(name, entity);
|
||||||
@@ -70,20 +69,27 @@ export class AppReduced {
|
|||||||
|
|
||||||
get options() {
|
get options() {
|
||||||
return {
|
return {
|
||||||
basepath: "",
|
basepath: "/",
|
||||||
logo_return_path: "/",
|
logo_return_path: "/",
|
||||||
...this._options,
|
...this._options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withBasePath(path: string | string[], absolute = false): string {
|
||||||
|
const paths = Array.isArray(path) ? path : [path];
|
||||||
|
return [absolute ? "~" : null, this.options.basepath, ...paths]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/")
|
||||||
|
.replace(/\/+/g, "/")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
getSettingsPath(path: string[] = []): string {
|
getSettingsPath(path: string[] = []): string {
|
||||||
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
|
return this.withBasePath(["settings", ...path], true);
|
||||||
return [base, ...path].join("/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAbsolutePath(path?: string): string {
|
getAbsolutePath(path?: string): string {
|
||||||
const { basepath } = this.options;
|
return this.withBasePath(path ?? [], true);
|
||||||
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthConfig() {
|
getAuthConfig() {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const Group = <E extends ElementType = "div">({
|
|||||||
{...props}
|
{...props}
|
||||||
data-role="group"
|
data-role="group"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col gap-1.5 w-full",
|
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
|
||||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||||
as === "fieldset" && error && "border-red-500",
|
as === "fieldset" && error && "border-red-500",
|
||||||
error && "text-red-500",
|
error && "text-red-500",
|
||||||
@@ -97,7 +97,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
||||||
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed",
|
||||||
!disabledOrReadonly &&
|
!disabledOrReadonly &&
|
||||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||||
props.className,
|
props.className,
|
||||||
@@ -154,7 +154,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -214,7 +214,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
|||||||
{...props}
|
{...props}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed scale-150 ml-1"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={handleCheck}
|
onChange={handleCheck}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
@@ -260,7 +260,6 @@ export const Switch = forwardRef<
|
|||||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onCheckedChange={(bool) => {
|
onCheckedChange={(bool) => {
|
||||||
console.log("setting", bool);
|
|
||||||
props.onChange?.({ target: { value: bool } });
|
props.onChange?.({ target: { value: bool } });
|
||||||
}}
|
}}
|
||||||
{...(props as any)}
|
{...(props as any)}
|
||||||
@@ -294,7 +293,7 @@ export const Select = forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
||||||
"appearance-none h-11 w-full",
|
"appearance-none h-11 w-full",
|
||||||
!props.multiple && "border-r-8 border-r-transparent",
|
!props.multiple && "border-r-8 border-r-transparent",
|
||||||
props.className,
|
props.className,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
||||||
import { useEvent } from "../../hooks/use-event";
|
import { useEvent } from "../../hooks/use-event";
|
||||||
|
import { useBkndOptions } from "ui/client/BkndProvider";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Transforms `path` into its relative `base` version
|
* Transforms `path` into its relative `base` version
|
||||||
@@ -70,11 +71,13 @@ export function Link({
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const bkndOptions = useBkndOptions();
|
||||||
|
const adminBasePath = bkndOptions?.admin_basepath ?? "";
|
||||||
const _href = props.href ?? props.to;
|
const _href = props.href ?? props.to;
|
||||||
|
|
||||||
const href = router
|
const href = router
|
||||||
.hrefs(
|
.hrefs(
|
||||||
_href[0] === "~" ? _href.slice(1) : router.base + _href,
|
_href[0] === "~" ? adminBasePath + _href.slice(1) : router.base + _href,
|
||||||
router, // pass router as a second argument for convinience
|
router, // pass router as a second argument for convinience
|
||||||
)
|
)
|
||||||
.replace("//", "/");
|
.replace("//", "/");
|
||||||
@@ -82,8 +85,9 @@ export function Link({
|
|||||||
const active =
|
const active =
|
||||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||||
|
|
||||||
|
// console.log({ adminBasePath, _href, href, bkndOptions })
|
||||||
if (native) {
|
if (native) {
|
||||||
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
return <a className={`${active ? "active " : ""}${className}`} {...props} href={href} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wouterOnClick = (e: any) => {
|
const wouterOnClick = (e: any) => {
|
||||||
@@ -99,10 +103,11 @@ export function Link({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// @ts-expect-error className is not typed on WouterLink
|
||||||
<WouterLink
|
<WouterLink
|
||||||
// @ts-expect-error className is not typed on WouterLink
|
|
||||||
className={`${active ? "active " : ""}${className}`}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
className={`${active ? "active " : ""}${className}`}
|
||||||
|
href={`~${href}`}
|
||||||
onClick={wouterOnClick}
|
onClick={wouterOnClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AuthForm({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Password name="password" required minLength={8} />
|
<Password name="password" required minLength={1} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
|
|||||||
ActiveIcon?: any;
|
ActiveIcon?: any;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SectionHeaderAccordionItem = ({
|
export const SectionHeaderAccordionItem = ({
|
||||||
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
ActiveIcon = IconChevronUp,
|
ActiveIcon = IconChevronUp,
|
||||||
children,
|
children,
|
||||||
renderHeaderRight,
|
renderHeaderRight,
|
||||||
|
scrollContainerRef,
|
||||||
}: SectionHeaderAccordionItemProps) => (
|
}: SectionHeaderAccordionItemProps) => (
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 49 }}
|
style={{ minHeight: 49 }}
|
||||||
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
: "flex-initial cursor-pointer hover:bg-primary/5",
|
: "flex-initial cursor-pointer hover:bg-primary/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
|
||||||
|
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
|
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
|
||||||
@@ -501,14 +505,12 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
>
|
>
|
||||||
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
|
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
|
||||||
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
|
||||||
<div className="flex flex-grow" />
|
<div className="flex grow" />
|
||||||
{renderHeaderRight?.({ open })}
|
{renderHeaderRight?.({ open })}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
ref={scrollContainerRef}
|
||||||
"overflow-y-scroll transition-all",
|
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
|
||||||
open ? " flex-grow" : "h-0 opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||||
routePattern,
|
routePattern,
|
||||||
identifier,
|
identifier,
|
||||||
|
renderHeaderRight,
|
||||||
...props
|
...props
|
||||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
|
||||||
|
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
|
||||||
// it's optional because it could be provided using the context
|
// it's optional because it could be provided using the context
|
||||||
routePattern?: string;
|
routePattern?: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
||||||
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
return (
|
||||||
|
<SectionHeaderAccordionItem
|
||||||
|
{...props}
|
||||||
|
open={active}
|
||||||
|
toggle={toggle}
|
||||||
|
renderHeaderRight={
|
||||||
|
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
import { SegmentedControl } from "@mantine/core";
|
||||||
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
TbDatabase,
|
TbDatabase,
|
||||||
@@ -24,34 +24,35 @@ import { useLocation } from "wouter";
|
|||||||
import { NavLink } from "./AppShell";
|
import { NavLink } from "./AppShell";
|
||||||
import { autoFormatString } from "core/utils";
|
import { autoFormatString } from "core/utils";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { getVersion } from "core/env";
|
import { getVersion, isDebug } from "core/env";
|
||||||
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
|
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
|
||||||
import { useAppShellAdminOptions } from "ui/options";
|
import { useAppShellAdminOptions } from "ui/options";
|
||||||
|
|
||||||
export function HeaderNavigation() {
|
export function HeaderNavigation() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
|
const { config } = useBknd();
|
||||||
|
|
||||||
const items: {
|
const items: {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
Icon: any;
|
Icon?: any;
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
/*{
|
|
||||||
label: "Base",
|
|
||||||
href: "#",
|
|
||||||
exact: true,
|
|
||||||
Icon: TbLayoutDashboard,
|
|
||||||
disabled: true,
|
|
||||||
tooltip: "Coming soon"
|
|
||||||
},*/
|
|
||||||
{ label: "Data", href: "/data", Icon: TbDatabase },
|
{ label: "Data", href: "/data", Icon: TbDatabase },
|
||||||
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
|
||||||
{ label: "Media", href: "/media", Icon: TbPhoto },
|
{ label: "Media", href: "/media", Icon: TbPhoto },
|
||||||
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isDebug() || Object.keys(config.flows?.flows ?? {}).length > 0) {
|
||||||
|
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.server.mcp.enabled) {
|
||||||
|
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
|
||||||
|
}
|
||||||
|
|
||||||
const activeItem = items.find((item) =>
|
const activeItem = items.find((item) =>
|
||||||
item.exact ? location === item.href : location.startsWith(item.href),
|
item.exact ? location === item.href : location.startsWith(item.href),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function useNavigate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
const state = {
|
const state = {
|
||||||
...options?.state,
|
...options?.state,
|
||||||
referrer: location,
|
referrer: location,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ReactDOM from "react-dom/client";
|
import * as ReactDOM from "react-dom/client";
|
||||||
import Admin from "./Admin";
|
import Admin from "./Admin";
|
||||||
//import "./main.css";
|
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function EntityForm({
|
|||||||
className,
|
className,
|
||||||
action,
|
action,
|
||||||
}: EntityFormProps) {
|
}: EntityFormProps) {
|
||||||
const fields = entity.getFillableFields(action, true);
|
const fields = entity.getFields({ virtual: true, primary: false });
|
||||||
const options = useEntityAdminOptions(entity, action);
|
const options = useEntityAdminOptions(entity, action);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,10 +93,6 @@ export function EntityForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!field.isFillable(action)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _key = `${entity.name}-${field.name}-${key}`;
|
const _key = `${entity.name}-${field.name}-${key}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +124,7 @@ export function EntityForm({
|
|||||||
<EntityFormField
|
<EntityFormField
|
||||||
field={field}
|
field={field}
|
||||||
fieldApi={props}
|
fieldApi={props}
|
||||||
disabled={fieldsDisabled}
|
disabled={fieldsDisabled || !field.isFillable(action)}
|
||||||
tabIndex={key + 1}
|
tabIndex={key + 1}
|
||||||
action={action}
|
action={action}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function useEntityForm({
|
|||||||
}: EntityFormProps) {
|
}: EntityFormProps) {
|
||||||
const data = initialData ?? {};
|
const data = initialData ?? {};
|
||||||
// @todo: check if virtual must be filtered
|
// @todo: check if virtual must be filtered
|
||||||
const fields = entity.getFillableFields(action, true);
|
const fields = entity.getFields({ virtual: true, primary: false });
|
||||||
|
|
||||||
// filter defaultValues to only contain fillable fields
|
// filter defaultValues to only contain fillable fields
|
||||||
const defaultValues = getDefaultValues(fields, data);
|
const defaultValues = getDefaultValues(fields, data);
|
||||||
|
|||||||
@@ -378,11 +378,7 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const Policy = ({
|
const Policy = ({ permission }: { permission: TPermission }) => {
|
||||||
permission,
|
|
||||||
}: {
|
|
||||||
permission: TPermission;
|
|
||||||
}) => {
|
|
||||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||||
}));
|
}));
|
||||||
@@ -503,22 +499,24 @@ const CustomFieldWrapper = ({
|
|||||||
className: "max-w-none",
|
className: "max-w-none",
|
||||||
}}
|
}}
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
target={() =>
|
target={() => (
|
||||||
typeof schema.content === "object" ? (
|
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll">
|
||||||
<JsonViewer
|
{typeof schema.content === "object" ? (
|
||||||
className="w-auto max-w-120 bg-background pr-3 text-sm"
|
<JsonViewer
|
||||||
json={schema.content}
|
className="pr-3 text-sm"
|
||||||
title={schema.name}
|
json={schema.content}
|
||||||
expand={5}
|
title={schema.name}
|
||||||
/>
|
expand={5}
|
||||||
) : (
|
/>
|
||||||
<CodePreview
|
) : (
|
||||||
code={schema.content}
|
<CodePreview
|
||||||
lang="typescript"
|
code={schema.content}
|
||||||
className="w-auto max-w-120 bg-background p-3 text-sm"
|
lang="typescript"
|
||||||
/>
|
className="p-3 text-sm"
|
||||||
)
|
/>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||||
{autoFormatString(schema.name)}
|
{autoFormatString(schema.name)}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import { useBkndWindowContext } from "bknd/client";
|
|||||||
import ToolsRoutes from "./tools";
|
import ToolsRoutes from "./tools";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const TestRoutes = lazy(() => import("./test"));
|
let TestRoutes: any;
|
||||||
|
try {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
TestRoutes = lazy(() => import("./test"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
export function Routes({
|
export function Routes({
|
||||||
BkndWrapper,
|
BkndWrapper,
|
||||||
@@ -28,7 +33,7 @@ export function Routes({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const ctx = useBkndWindowContext();
|
const ctx = useBkndWindowContext();
|
||||||
const actualBasePath = basePath || ctx.admin_basepath;
|
const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="bknd-admin" className={theme + " antialiased"}>
|
<div id="bknd-admin" className={theme + " antialiased"}>
|
||||||
@@ -43,11 +48,13 @@ export function Routes({
|
|||||||
<Route path="/" nest>
|
<Route path="/" nest>
|
||||||
<Root>
|
<Root>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/test*" nest>
|
{TestRoutes && (
|
||||||
<Suspense fallback={null}>
|
<Route path="/test*" nest>
|
||||||
<TestRoutes />
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<TestRoutes />
|
||||||
</Route>
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
|||||||
@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
|||||||
try {
|
try {
|
||||||
const user_entity = config.entity_name ?? "users";
|
const user_entity = config.entity_name ?? "users";
|
||||||
const entities = _s.config.data.entities ?? {};
|
const entities = _s.config.data.entities ?? {};
|
||||||
console.log("entities", entities, user_entity);
|
|
||||||
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
||||||
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (user_fields.length > 0) {
|
if (user_fields.length > 0) {
|
||||||
console.log("user_fields", user_fields);
|
|
||||||
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
||||||
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
||||||
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
|
||||||
console.log("_s", _s);
|
const roles = Object.keys(config.roles ?? {});
|
||||||
|
if (roles.length > 0) {
|
||||||
|
_schema.properties.default_role_register.enum = roles;
|
||||||
|
}
|
||||||
|
} catch (_e) {}
|
||||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||||
/* if (_s.permissions) {
|
/* if (_s.permissions) {
|
||||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route path="/" component={ToolsIndex} />
|
<Route path="/" component={ToolsIndex} />
|
||||||
<Route path="/mcp" component={ToolsMcp} />
|
<Route path="/mcp*" component={ToolsMcp} nest />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import { Empty } from "ui/components/display/Empty";
|
|||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
|
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||||
|
import { Route, Switch } from "wouter";
|
||||||
|
|
||||||
export default function ToolsMcp() {
|
export default function ToolsMcp() {
|
||||||
useBrowserTitle(["MCP UI"]);
|
useBrowserTitle(["MCP UI"]);
|
||||||
|
|
||||||
const { config } = useBknd();
|
const { config } = useBknd();
|
||||||
const feature = useMcpStore((state) => state.feature);
|
|
||||||
const setFeature = useMcpStore((state) => state.setFeature);
|
|
||||||
const content = useMcpStore((state) => state.content);
|
|
||||||
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
||||||
const mcpPath = config.server.mcp.path;
|
const mcpPath = config.server.mcp.path;
|
||||||
|
|
||||||
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-grow max-w-screen">
|
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
|
||||||
<AppShell.SectionHeader>
|
<div className="flex flex-col flex-grow max-w-screen">
|
||||||
<div className="flex flex-row gap-4 items-center">
|
<AppShell.SectionHeader>
|
||||||
<McpIcon />
|
<div className="flex flex-row gap-4 items-center">
|
||||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
<McpIcon />
|
||||||
MCP UI
|
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||||
</AppShell.SectionHeaderTitle>
|
MCP UI
|
||||||
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
</AppShell.SectionHeaderTitle>
|
||||||
<TbWorld />
|
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
||||||
<div className="min-w-0 flex-1">
|
<TbWorld />
|
||||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
<div className="min-w-0 flex-1">
|
||||||
{window.location.origin + mcpPath}
|
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||||
</span>
|
{window.location.origin + mcpPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell.SectionHeader>
|
||||||
</AppShell.SectionHeader>
|
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex grow h-full">
|
||||||
<AppShell.Sidebar>
|
<AppShell.Sidebar>
|
||||||
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
|
<Tools.Sidebar />
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
title="Resources"
|
title="Resources"
|
||||||
open={feature === "resources"}
|
identifier="resources"
|
||||||
toggle={() => setFeature("resources")}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
|
||||||
<i>Resources</i>
|
|
||||||
</div>
|
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
|
||||||
</AppShell.Sidebar>
|
|
||||||
{feature === "tools" && <Tools.Content />}
|
|
||||||
|
|
||||||
{!content && (
|
|
||||||
<Empty title="No tool selected" description="Please select a tool to continue.">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => openSidebar()}
|
|
||||||
className="block md:hidden"
|
|
||||||
>
|
>
|
||||||
Open Tools
|
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
||||||
</Button>
|
<i>Resources</i>
|
||||||
</Empty>
|
</div>
|
||||||
)}
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
|
</AppShell.Sidebar>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route path="/tools/:toolName?" component={Tools.Content} />
|
||||||
|
<Route path="*">
|
||||||
|
<Empty
|
||||||
|
title="No tool selected"
|
||||||
|
description="Please select a tool to continue."
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => openSidebar()}
|
||||||
|
className="block md:hidden"
|
||||||
|
>
|
||||||
|
Open Tools
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RoutePathStateProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,23 +3,16 @@ import { combine } from "zustand/middleware";
|
|||||||
|
|
||||||
import type { ToolJson } from "jsonv-ts/mcp";
|
import type { ToolJson } from "jsonv-ts/mcp";
|
||||||
|
|
||||||
const FEATURES = ["tools", "resources"] as const;
|
|
||||||
export type Feature = (typeof FEATURES)[number];
|
|
||||||
|
|
||||||
export const useMcpStore = create(
|
export const useMcpStore = create(
|
||||||
combine(
|
combine(
|
||||||
{
|
{
|
||||||
tools: [] as ToolJson[],
|
tools: [] as ToolJson[],
|
||||||
feature: "tools" as Feature | null,
|
|
||||||
content: null as ToolJson | null,
|
|
||||||
history: [] as { type: "request" | "response"; data: any }[],
|
history: [] as { type: "request" | "response"; data: any }[],
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
historyVisible: false,
|
historyVisible: false,
|
||||||
},
|
},
|
||||||
(set) => ({
|
(set) => ({
|
||||||
setTools: (tools: ToolJson[]) => set({ tools }),
|
setTools: (tools: ToolJson[]) => set({ tools }),
|
||||||
setFeature: (feature: Feature) => set({ feature }),
|
|
||||||
setContent: (content: ToolJson | null) => set({ content }),
|
|
||||||
addHistory: (type: "request" | "response", data: any) =>
|
addHistory: (type: "request" | "response", data: any) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
|
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell";
|
|||||||
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
|
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
|
||||||
import { twMerge } from "ui/elements/mocks/tailwind-merge";
|
|
||||||
import { Field, Form } from "ui/components/form/json-schema-form";
|
import { Field, Form } from "ui/components/form/json-schema-form";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
@@ -13,17 +12,18 @@ import { appShellStore } from "ui/store";
|
|||||||
import { Icon } from "ui/components/display/Icon";
|
import { Icon } from "ui/components/display/Icon";
|
||||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||||
import { Tooltip } from "@mantine/core";
|
import { Tooltip } from "@mantine/core";
|
||||||
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
|
||||||
export function Sidebar({ open, toggle }) {
|
export function Sidebar() {
|
||||||
const client = useMcpClient();
|
const client = useMcpClient();
|
||||||
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
||||||
const tools = useMcpStore((state) => state.tools);
|
const tools = useMcpStore((state) => state.tools);
|
||||||
const setTools = useMcpStore((state) => state.setTools);
|
const setTools = useMcpStore((state) => state.setTools);
|
||||||
const setContent = useMcpStore((state) => state.setContent);
|
|
||||||
const content = useMcpStore((state) => state.content);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(undefined!);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleRefresh();
|
handleRefresh().then(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
const selectedTool = scrollContainerRef.current.querySelector(".active");
|
||||||
|
if (selectedTool) {
|
||||||
|
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.SectionHeaderAccordionItem
|
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||||
|
scrollContainerRef={scrollContainerRef}
|
||||||
title="Tools"
|
title="Tools"
|
||||||
open={open}
|
identifier="tools"
|
||||||
toggle={toggle}
|
renderHeaderRight={({ active }) => (
|
||||||
renderHeaderRight={() => (
|
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
{error && (
|
{error && (
|
||||||
<Tooltip label={error}>
|
<Tooltip label={error}>
|
||||||
@@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) {
|
|||||||
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||||
{tools.length}
|
{tools.length}
|
||||||
</span>
|
</span>
|
||||||
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
|
<IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
|
|||||||
return (
|
return (
|
||||||
<AppShell.SidebarLink
|
<AppShell.SidebarLink
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className={twMerge(
|
className="flex flex-col items-start h-auto py-3 gap-px"
|
||||||
"flex flex-col items-start h-auto py-3 gap-px",
|
as={Link}
|
||||||
content?.name === tool.name ? "active" : "",
|
href={`/tools/${tool.name}`}
|
||||||
)}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContent(tool);
|
//setContent(tool);
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.SectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Content() {
|
export function Content() {
|
||||||
const content = useMcpStore((state) => state.content);
|
const { toolName } = useParams();
|
||||||
|
const tools = useMcpStore((state) => state.tools);
|
||||||
|
const tool = tools.find((tool) => tool.name === toolName);
|
||||||
const addHistory = useMcpStore((state) => state.addHistory);
|
const addHistory = useMcpStore((state) => state.addHistory);
|
||||||
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
|
const [payload, setPayload] = useState<object>(getTemplate(tool?.inputSchema));
|
||||||
const [result, setResult] = useState<object | null>(null);
|
const [result, setResult] = useState<object | null>(null);
|
||||||
const historyVisible = useMcpStore((state) => state.historyVisible);
|
const historyVisible = useMcpStore((state) => state.historyVisible);
|
||||||
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
||||||
const client = useMcpClient();
|
const client = useMcpClient();
|
||||||
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
||||||
const hasInputSchema =
|
const hasInputSchema =
|
||||||
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPayload(getTemplate(content?.inputSchema));
|
setPayload(getTemplate(tool?.inputSchema));
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}, [content]);
|
}, [toolName]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!content?.name) return;
|
if (!tool?.name) return;
|
||||||
const request = {
|
const request = {
|
||||||
name: content.name,
|
name: tool.name,
|
||||||
arguments: payload,
|
arguments: payload,
|
||||||
};
|
};
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -131,7 +139,7 @@ export function Content() {
|
|||||||
});
|
});
|
||||||
}, [payload]);
|
}, [payload]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!tool) return null;
|
||||||
|
|
||||||
let readableResult = result;
|
let readableResult = result;
|
||||||
try {
|
try {
|
||||||
@@ -144,11 +152,11 @@ export function Content() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
className="flex grow flex-col min-w-0 max-w-screen"
|
||||||
key={content.name}
|
key={tool.name}
|
||||||
schema={{
|
schema={{
|
||||||
title: "InputSchema",
|
title: "InputSchema",
|
||||||
...content?.inputSchema,
|
...tool?.inputSchema,
|
||||||
}}
|
}}
|
||||||
validateOn="submit"
|
validateOn="submit"
|
||||||
initialValues={payload}
|
initialValues={payload}
|
||||||
@@ -163,12 +171,12 @@ export function Content() {
|
|||||||
right={
|
right={
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
Icon={historyVisible ? TbHistoryOff : TbHistory}
|
||||||
onClick={() => setHistoryVisible(!historyVisible)}
|
onClick={() => setHistoryVisible(!historyVisible)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!content?.name || isPending}
|
disabled={!tool?.name || isPending}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
@@ -181,19 +189,19 @@ export function Content() {
|
|||||||
<span className="opacity-50">
|
<span className="opacity-50">
|
||||||
Tools <span className="opacity-70">/</span>
|
Tools <span className="opacity-70">/</span>
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="truncate">{content?.name}</span>
|
<span className="truncate">{tool?.name}</span>
|
||||||
</AppShell.SectionHeaderTitle>
|
</AppShell.SectionHeaderTitle>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<div className="flex flex-grow flex-row w-vw">
|
<div className="flex grow flex-row w-vw">
|
||||||
<div
|
<div
|
||||||
className="flex flex-grow flex-col max-w-full"
|
className="flex grow flex-col max-w-full"
|
||||||
style={{
|
style={{
|
||||||
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
<div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
|
||||||
<p className="text-primary/80">{content?.description}</p>
|
<p className="text-primary/80">{tool?.description}</p>
|
||||||
|
|
||||||
{hasInputSchema && <Field name="" />}
|
{hasInputSchema && <Field name="" />}
|
||||||
<JsonViewerTabs
|
<JsonViewerTabs
|
||||||
@@ -209,7 +217,7 @@ export function Content() {
|
|||||||
},
|
},
|
||||||
Result: { json: readableResult, title: "Result" },
|
Result: { json: readableResult, title: "Result" },
|
||||||
Configuration: {
|
Configuration: {
|
||||||
json: content ?? null,
|
json: tool ?? null,
|
||||||
title: "Configuration",
|
title: "Configuration",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -234,7 +242,7 @@ const History = () => {
|
|||||||
<>
|
<>
|
||||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-col flex-grow p-3 gap-1">
|
<div className="flex grow flex-col p-3 gap-1">
|
||||||
{history.map((item, i) => (
|
{history.map((item, i) => (
|
||||||
<JsonViewer
|
<JsonViewer
|
||||||
key={`${item.type}-${i}`}
|
key={`${item.type}-${i}`}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist/types",
|
"outDir": "./dist/types",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"baseUrl": ".",
|
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
},
|
||||||
},
|
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
"exclude": ["./node_modules", "./__test__", "./e2e"]
|
||||||
"exclude": ["./node_modules", "./__test__", "./e2e"]
|
|
||||||
}
|
}
|
||||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user