mirror of
https://github.com/bknd-io/bknd.git
synced 2026-10-25 11:48:31 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f591fc4a | ||
|
|
6bbc922710 |
No files matched your search
-267
@@ -1,267 +0,0 @@
|
|||||||
# 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
|
|
||||||
+77
-169
@@ -1,202 +1,110 @@
|
|||||||
|
# Functional Source License, Version 1.1, MIT Future License
|
||||||
|
|
||||||
Apache License
|
## Abbreviation
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
FSL-1.1-MIT
|
||||||
|
|
||||||
1. Definitions.
|
## Notice
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
Copyright 2025 Dennis Senn
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
## Terms and Conditions
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
### Licensor ("We")
|
||||||
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.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
The party offering the Software under these Terms and Conditions.
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
### The Software
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
The "Software" is each version of the software that we make available under
|
||||||
transformation or translation of a Source form, including but
|
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||||
not limited to compiled object code, generated documentation,
|
Conditions with the Software.
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
### License Grant
|
||||||
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).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
Subject to your compliance with this License Grant and the Patents,
|
||||||
form, that is based on (or derived from) the Work and for which the
|
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
and redistribute the Software for any Permitted Purpose identified below.
|
||||||
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.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
### Permitted Purpose
|
||||||
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."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
means making the Software available to others in a commercial product or
|
||||||
subsequently incorporated within the Work.
|
service that:
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
1. substitutes for the Software;
|
||||||
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.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
2. substitutes for any other product or service we offer using the Software
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
that exists as of the date we make the Software available; or
|
||||||
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.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
3. offers the same or substantially similar functionality as the Software.
|
||||||
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:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
Permitted Purposes specifically include using the Software:
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
1. for your internal use and access;
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
2. for non-commercial education;
|
||||||
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
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
3. for non-commercial research; and
|
||||||
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.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
4. in connection with professional services that you provide to a licensee
|
||||||
may provide additional or different license terms and conditions
|
using the Software in accordance with these 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.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
### Patents
|
||||||
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.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
patents, the license grant above includes a license under our patents. If you
|
||||||
except as required for reasonable and customary use in describing the
|
make a claim against any party that the Software infringes or contributes to
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
the infringement of any patent, then your patent license to the Software ends
|
||||||
|
immediately.
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
### Redistribution
|
||||||
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.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
the Software.
|
||||||
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.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
If you redistribute any copies, modifications or derivatives of the Software,
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
you must include a copy of or a link to these Terms and Conditions and not
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
remove any copyright notices provided in or with the Software.
|
||||||
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.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
### Disclaimer
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||||
replaced with your own identifying information. (Don't include
|
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||||
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.
|
|
||||||
|
|
||||||
Copyright 2026 Dennis Senn
|
### Trademarks
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Except for displaying the License Details and identifying us as the origin of
|
||||||
you may not use this file except in compliance with the License.
|
the Software, you have no right under these Terms and Conditions to use our
|
||||||
You may obtain a copy of the License at
|
trademarks, trade names, service marks or product names.
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
## Grant of Future License
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
We hereby irrevocably grant you an additional license to use the Software under
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
the MIT license that is effective on the second anniversary of the date we make
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
the Software available. On or after that date, you may use the Software under
|
||||||
See the License for the specific language governing permissions and
|
the MIT license, in which case the following will apply:
|
||||||
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,19 +165,3 @@ 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).
|
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
__test__/helper.d.ts
|
__test__/helper.d.ts
|
||||||
.env.local
|
|
||||||
@@ -1,76 +1,66 @@
|
|||||||
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, omitKeys } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } 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", () => {
|
||||||
describe("makeConfig", () => {
|
it("makes config", async () => {
|
||||||
it("returns empty config for empty inputs", async () => {
|
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
||||||
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
expect(
|
||||||
[{}],
|
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
||||||
[{}, { env: { TEST: "test" } }],
|
).toEqual({});
|
||||||
];
|
|
||||||
|
|
||||||
for (const args of cases) {
|
// merges everything returned from `app` with the config
|
||||||
const cfg = await adapter.makeConfig(...(args as any));
|
expect(
|
||||||
expect(stripConnection(cfg)).toEqual({});
|
omitKeys(
|
||||||
}
|
await adapter.makeConfig(
|
||||||
});
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
|
{ env: { TEST: "test" } },
|
||||||
it("merges app output into config", async () => {
|
),
|
||||||
const cfg = await adapter.makeConfig(
|
["connection"],
|
||||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
),
|
||||||
{ env: { TEST: "test" } },
|
).toEqual({
|
||||||
);
|
config: { server: { cors: { origin: "test" } } },
|
||||||
|
|
||||||
expect(stripConnection(cfg)).toEqual({
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows all properties in app() result", async () => {
|
it("allows all properties in app function", async () => {
|
||||||
const called = mock(() => null);
|
const called = mock(() => null);
|
||||||
|
const config = await adapter.makeConfig(
|
||||||
const cfg = await adapter.makeConfig(
|
{
|
||||||
{
|
app: (env) => ({
|
||||||
app: (env) => ({
|
connection: { url: "test" },
|
||||||
connection: { url: "test" },
|
config: { server: { cors: { origin: "test" } } },
|
||||||
config: { server: { cors: { origin: "test" } } },
|
options: {
|
||||||
options: { mode: "db" as const },
|
mode: "db",
|
||||||
onBuilt: () => {
|
},
|
||||||
called();
|
onBuilt: () => {
|
||||||
expect(env).toEqual({ foo: "bar" });
|
called();
|
||||||
},
|
expect(env).toEqual({ foo: "bar" });
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
{ 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();
|
||||||
|
});
|
||||||
|
|
||||||
expect(cfg.connection).toEqual({ url: "test" });
|
adapterTestSuite(bunTestRunner, {
|
||||||
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,10 +70,4 @@ 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, mock } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
|
import { getFileFromContext, isFile, isReadableStream } 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()) as File;
|
const blob = await res.res.blob();
|
||||||
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,11 +110,10 @@ 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.getFile(name);
|
const res = await api.getFileStream(name);
|
||||||
const stream = await api.getFileStream(name);
|
expect(isReadableStream(res)).toBe(true);
|
||||||
expect(isReadableStream(stream)).toBe(true);
|
|
||||||
|
|
||||||
const blob = (await res.res.blob()) as File;
|
const blob = await new Response(res).blob();
|
||||||
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");
|
||||||
@@ -163,30 +162,4 @@ 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,27 +38,4 @@ 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")
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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, 500, 20);
|
await $waitUntil("Postgres is running", isPostgresRunning);
|
||||||
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 "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"',
|
'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"',
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1]);
|
expect(res2.parameters).toEqual([1]);
|
||||||
});
|
});
|
||||||
@@ -192,7 +192,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ single: {} },
|
{ single: {} },
|
||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toMatchSnapshot();
|
expect(res.sql).toBe(
|
||||||
|
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" 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(
|
||||||
@@ -202,7 +204,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ multiple: {} },
|
{ multiple: {} },
|
||||||
);
|
);
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
expect(res2.sql).toMatchSnapshot();
|
expect(res2.sql).toBe(
|
||||||
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" 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]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,7 +271,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
//prettyPrintQb(qb);
|
//prettyPrintQb(qb);
|
||||||
expect(qb.compile().sql).toMatchSnapshot();
|
expect(qb.compile().sql).toBe(
|
||||||
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) 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]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,7 +313,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(qb.compile().sql).toMatchSnapshot();
|
expect(qb.compile().sql).toBe(
|
||||||
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) 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]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// 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,15 +1,9 @@
|
|||||||
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 {
|
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||||
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);
|
||||||
@@ -68,12 +62,12 @@ const configs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
function createAuthApp() {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
config: {
|
config: {
|
||||||
auth: mergeObject(configs.auth, config ?? {}),
|
auth: configs.auth,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,16 +132,6 @@ 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",
|
||||||
@@ -261,61 +245,4 @@ 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,7 +8,6 @@ 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();
|
||||||
@@ -129,87 +128,4 @@ 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({ forceDev: true });
|
app.registerAdminController();
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
@@ -223,32 +223,4 @@ 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,9 +7,7 @@ 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);
|
||||||
@@ -63,7 +61,7 @@ async function getRawConfig(
|
|||||||
return await db
|
return await db
|
||||||
.selectFrom("__bknd")
|
.selectFrom("__bknd")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("version", "=", opts?.version ?? CURRENT_VERSION)
|
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.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();
|
||||||
}
|
}
|
||||||
@@ -117,6 +115,7 @@ 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;
|
||||||
|
|
||||||
@@ -130,15 +129,4 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+12
-1
@@ -3,7 +3,17 @@ 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 = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
const external = [
|
||||||
|
"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"],
|
||||||
@@ -11,6 +21,7 @@ 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",
|
||||||
|
|||||||
+255
-353
@@ -2,8 +2,6 @@ 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");
|
||||||
@@ -14,178 +12,163 @@ 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)");
|
||||||
// Cross-platform clean: remove all files/folders in dist except static
|
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||||
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,
|
||||||
// don't use tsup's broken watch, we'll handle it ourselves
|
watch,
|
||||||
watch: false,
|
define,
|
||||||
define,
|
entry: [
|
||||||
entry: [
|
"src/index.ts",
|
||||||
"src/index.ts",
|
"src/core/utils/index.ts",
|
||||||
"src/core/utils/index.ts",
|
"src/plugins/index.ts",
|
||||||
"src/plugins/index.ts",
|
"src/modes/index.ts",
|
||||||
"src/modes/index.ts",
|
],
|
||||||
],
|
outDir: "dist",
|
||||||
outDir: "dist",
|
external: [...external],
|
||||||
external: [...external],
|
metafile: true,
|
||||||
metafile: true,
|
target: "esnext",
|
||||||
target: "esnext",
|
platform: "browser",
|
||||||
platform: "browser",
|
removeNodeProtocol: false,
|
||||||
removeNodeProtocol: false,
|
format: ["esm"],
|
||||||
format: ["esm"],
|
splitting: false,
|
||||||
splitting: false,
|
loader: {
|
||||||
loader: {
|
".svg": "dataurl",
|
||||||
".svg": "dataurl",
|
},
|
||||||
},
|
onSuccess: async () => {
|
||||||
onSuccess: async () => {
|
delayTypes();
|
||||||
delayTypes();
|
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
||||||
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(
|
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
||||||
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: false,
|
watch,
|
||||||
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"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,243 +177,162 @@ 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: false,
|
watch,
|
||||||
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(
|
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||||
adapter: string,
|
return {
|
||||||
overrides: Partial<tsup.Options> = {},
|
minify,
|
||||||
): tsup.Options {
|
sourcemap,
|
||||||
return {
|
watch,
|
||||||
minify,
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
sourcemap,
|
format: ["esm"],
|
||||||
watch: false,
|
platform: "neutral",
|
||||||
entry: [`src/adapter/${adapter}/index.ts`],
|
outDir: `dist/adapter/${adapter}`,
|
||||||
format: ["esm"],
|
metafile: true,
|
||||||
platform: "neutral",
|
splitting: false,
|
||||||
outDir: `dist/adapter/${adapter}`,
|
removeNodeProtocol: false,
|
||||||
metafile: true,
|
onSuccess: async () => {
|
||||||
splitting: false,
|
delayTypes();
|
||||||
removeNodeProtocol: false,
|
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||||
onSuccess: async () => {
|
},
|
||||||
delayTypes();
|
...overrides,
|
||||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
define: {
|
||||||
},
|
...define,
|
||||||
...overrides,
|
...overrides.define,
|
||||||
define: {
|
},
|
||||||
...define,
|
external: [
|
||||||
...overrides.define,
|
/^cloudflare*/,
|
||||||
},
|
/^@?hono.*?/,
|
||||||
external: [
|
/^(bknd|react|next|node).*?/,
|
||||||
/^cloudflare*/,
|
/.*\.(html)$/,
|
||||||
/^@?hono.*?/,
|
...external,
|
||||||
/^(bknd|react|next|node).*?/,
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
/.*\.(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
|
|
||||||
tsup.build(baseConfig("react-router")),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("browser", {
|
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
tsup.build(
|
// specific adatpers
|
||||||
baseConfig("bun", {
|
tsup.build(baseConfig("react-router")),
|
||||||
external: [/^bun\:.*/],
|
tsup.build(
|
||||||
|
baseConfig("browser", {
|
||||||
|
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(baseConfig("astro")),
|
tsup.build({
|
||||||
tsup.build(baseConfig("aws")),
|
...baseConfig("nextjs"),
|
||||||
tsup.build(
|
platform: "node",
|
||||||
baseConfig("cloudflare", {
|
|
||||||
external: ["wrangler", "node:process"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
tsup.build(
|
tsup.build({
|
||||||
baseConfig("cloudflare/proxy", {
|
...baseConfig("node"),
|
||||||
target: "esnext",
|
platform: "node",
|
||||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
|
||||||
outDir: "dist/adapter/cloudflare",
|
|
||||||
metafile: false,
|
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("sqlite/edge"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("nextjs"),
|
...baseConfig("sqlite/node"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("tanstack-start"),
|
...baseConfig("sqlite/bun"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
tsup.build({
|
external: [/^bun\:.*/],
|
||||||
...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\:.*/],
|
|
||||||
}),
|
|
||||||
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAll() {
|
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||||
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(() => {});
|
|
||||||
}
|
|
||||||
+2
-3
@@ -3,11 +3,10 @@ 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: RunOptions,
|
opts: Bun.SpawnOptions.OptionsObject & {},
|
||||||
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) => {
|
||||||
@@ -18,7 +17,7 @@ async function run(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Read from stdout
|
// Read from stdout
|
||||||
const reader = proc.stdout.getReader();
|
const reader = (proc.stdout as ReadableStream).getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
// Function to read chunks
|
// Function to read chunks
|
||||||
|
|||||||
+31
-40
@@ -1,53 +1,44 @@
|
|||||||
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",
|
||||||
},
|
},
|
||||||
astro: {
|
nextjs: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
node: {
|
astro: {
|
||||||
base_path: "",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
bun: {
|
node: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
"tanstack-start": {
|
bun: {
|
||||||
base_path: "/admin",
|
base_path: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
+7
-34
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.21.0",
|
"version": "0.20.0-rc.1",
|
||||||
"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": "Apache-2.0",
|
"license": "FSL-1.1-MIT",
|
||||||
"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.20",
|
"dayjs": "^1.11.19",
|
||||||
"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,6 +80,7 @@
|
|||||||
"@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",
|
||||||
@@ -88,7 +89,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.63.0",
|
"@playwright/test": "^1.56.1",
|
||||||
"@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",
|
||||||
@@ -142,7 +143,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.45.4"
|
"wrangler": "^4.52.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.19.6"
|
"@hono/node-server": "^1.19.6"
|
||||||
@@ -233,11 +234,6 @@
|
|||||||
"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",
|
||||||
@@ -258,16 +254,6 @@
|
|||||||
"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",
|
||||||
@@ -278,11 +264,6 @@
|
|||||||
"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",
|
||||||
@@ -297,13 +278,9 @@
|
|||||||
"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"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -330,14 +307,10 @@
|
|||||||
"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\s+/i, "");
|
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
||||||
if (headerToken) {
|
if (headerToken) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ 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;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./nuxt.adapter";
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./solid-start.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./sveltekit.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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 }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./tanstack-start.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-29
@@ -46,22 +46,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +82,6 @@ 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();
|
||||||
@@ -188,20 +171,10 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser({
|
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||||
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;
|
||||||
@@ -210,7 +183,6 @@ 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,7 +13,6 @@ 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";
|
||||||
|
|
||||||
@@ -199,12 +198,7 @@ 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(
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
`/${name}`,
|
|
||||||
strategy.getController(this.auth.authenticator, {
|
|
||||||
allow_register: this.auth.config.allow_register,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.registerStrategyActions(strategy, hono);
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +210,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;
|
let user: DB["users"] | undefined = 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;
|
||||||
@@ -231,33 +225,26 @@ export class AuthController extends Controller {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||||
try {
|
mcp.tool(
|
||||||
const actions = this.auth.authenticator.strategy("password").getActions();
|
"auth_user_create",
|
||||||
if (actions.create) {
|
{
|
||||||
const schema = actions.create.schema;
|
description: "Create a new user",
|
||||||
mcp.tool(
|
inputSchema: s.object({
|
||||||
"auth_user_create",
|
email: s.string({ format: "email" }),
|
||||||
{
|
password: s.string({ minLength: 8 }),
|
||||||
description: "Create a new user",
|
role: s
|
||||||
inputSchema: s.object({
|
.string({
|
||||||
...schema.properties,
|
enum: roles.length > 0 ? roles : undefined,
|
||||||
role: s
|
})
|
||||||
.string({
|
.optional(),
|
||||||
enum: roles.length > 0 ? roles : undefined,
|
}),
|
||||||
})
|
},
|
||||||
.optional(),
|
async (params, c) => {
|
||||||
}),
|
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",
|
||||||
@@ -310,9 +297,7 @@ 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,7 +51,6 @@ 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,7 +74,6 @@ 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>;
|
||||||
@@ -165,13 +164,9 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,7 +425,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\s+/i, "");
|
token = bearerHeader.replace("Bearer ", "");
|
||||||
} 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,7 +10,6 @@ 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();
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
format: "email",
|
format: "email",
|
||||||
}),
|
}),
|
||||||
password: s.string({
|
password: s.string({
|
||||||
minLength: this.config.minLength,
|
minLength: 8, // @todo: this should be configurable
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,28 +65,19 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
return await bcryptCompare(compare, actual);
|
return await bcryptCompare(compare, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
return actual === compare;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(password: string) {
|
verify(password: string) {
|
||||||
return async (user: User) => {
|
return async (user: User) => {
|
||||||
if (!user || !user.strategy_value) {
|
const compare = await this.compare(user?.strategy_value!, password);
|
||||||
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, opts: { allow_register?: boolean }): Hono<any> {
|
getController(authenticator: Authenticator): 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(),
|
||||||
@@ -120,43 +110,41 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (opts.allow_register) {
|
hono.post(
|
||||||
hono.post(
|
"/register",
|
||||||
"/register",
|
describeRoute({
|
||||||
describeRoute({
|
summary: "Register a new user with email and password",
|
||||||
summary: "Register a new user with email and password",
|
tags: ["auth"],
|
||||||
tags: ["auth"],
|
}),
|
||||||
}),
|
jsc("query", redirectQuerySchema),
|
||||||
jsc("query", redirectQuerySchema),
|
async (c) => {
|
||||||
async (c) => {
|
try {
|
||||||
try {
|
const { redirect } = c.req.valid("query");
|
||||||
const { redirect } = c.req.valid("query");
|
const { password, email, ...body } = parse(
|
||||||
const { password, email, ...body } = parse(
|
payloadSchema,
|
||||||
payloadSchema,
|
await authenticator.getBody(c),
|
||||||
await authenticator.getBody(c),
|
{
|
||||||
{
|
onError: (errors) => {
|
||||||
onError: (errors) => {
|
$console.error("Invalid register payload", [...errors]);
|
||||||
$console.error("Invalid register payload", [...errors]);
|
new InvalidCredentialsException();
|
||||||
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, opts: { allow_register?: boolean }): Hono;
|
abstract getController(auth: Authenticator): 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, opts: { allow_register?: boolean }): Hono<any> {
|
getController(auth: Authenticator): 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,10 +379,6 @@ 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";
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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]>;
|
||||||
|
}
|
||||||
+9
-14
@@ -1,27 +1,22 @@
|
|||||||
{
|
{
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"jsx": "react-jsx",
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"skipLibCheck": true,
|
||||||
"types": ["vite/client"],
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": false,
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
/* Linting */
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"paths": {
|
"types": ["vite/client"]
|
||||||
"@/*": ["./src/*"]
|
},
|
||||||
}
|
"include": ["bknd-types.d.ts"]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { serve } from "bknd/adapter/cloudflare";
|
||||||
|
import config from "./bknd.config";
|
||||||
|
|
||||||
|
export default serve(config);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <div>Hello World</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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!" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
|
// });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { em, entity, text, boolean, number, datetime, json, jsonSchema, enumm } from "bknd";
|
||||||
|
|
||||||
|
export default em({
|
||||||
|
// todos: entity("todos", {
|
||||||
|
// title: text(),
|
||||||
|
// done: boolean(),
|
||||||
|
// }),
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.bknd/tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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,3 +9,4 @@ 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,47 +21,46 @@ 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"));
|
||||||
} else {
|
process.exit(0);
|
||||||
// @todo: currently assuming parameters aren't used
|
}
|
||||||
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
// @todo: currently assuming parameters aren't used
|
||||||
|
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.out) {
|
if (options.seed) {
|
||||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
console.info(c.dim("\nExecuting seed..."));
|
||||||
await writeFile(options.out, output);
|
const seed = app.options?.seed;
|
||||||
console.info(`SQL written to ${c.cyan(options.out)}`);
|
if (seed) {
|
||||||
|
await app.options?.seed?.({
|
||||||
|
...app.modules.ctx(),
|
||||||
|
app: app,
|
||||||
|
});
|
||||||
|
console.info(c.green("Seed executed"));
|
||||||
} else {
|
} else {
|
||||||
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
|
console.info(c.yellow("No seed function provided"));
|
||||||
|
|
||||||
console.info(
|
|
||||||
c.yellow(
|
|
||||||
"\nNo statements have been executed. Use --force to perform database syncing operations",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
if (options.out) {
|
||||||
if (options.seed) {
|
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||||
console.info(c.dim("\nExecuting seed..."));
|
await writeFile(options.out, output);
|
||||||
const seed = app.options?.seed;
|
console.info(`SQL written to ${c.cyan(options.out)}`);
|
||||||
if (seed) {
|
|
||||||
await 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",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/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,10 +136,8 @@ 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?: "create" | "update", include_virtual?: boolean): Field[] {
|
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||||
field.isFillable(context),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRequiredFields(): Field[] {
|
getRequiredFields(): Field[] {
|
||||||
@@ -191,15 +189,9 @@ export class Entity<
|
|||||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFields({
|
getFields(include_virtual: boolean = false): Field[] {
|
||||||
virtual = false,
|
if (include_virtual) return this.fields;
|
||||||
primary = true,
|
return this.fields.filter((f) => !f.isVirtual());
|
||||||
}: { 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) {
|
||||||
@@ -239,7 +231,7 @@ export class Entity<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = this.getFillableFields(context as any, false);
|
const fields = this.getFillableFields(context, 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);
|
||||||
@@ -283,7 +275,7 @@ export class Entity<
|
|||||||
fields = this.getFillableFields(options.context);
|
fields = this.getFillableFields(options.context);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fields = this.getFields({ virtual: true });
|
fields = this.getFields(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||||
|
|||||||
@@ -83,10 +83,8 @@ 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 as any)) {
|
if (!field.isFillable(context)) {
|
||||||
throw new Error(
|
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||||
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// transform from field
|
// transform from field
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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,19 +26,11 @@ export const baseFieldConfigSchema = s
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
label: s.string(),
|
label: s.string(),
|
||||||
description: s.string(),
|
description: s.string(),
|
||||||
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
required: s.boolean({ default: false }),
|
||||||
fillable: s.anyOf(
|
fillable: s.anyOf([
|
||||||
[
|
s.boolean({ title: "Boolean" }),
|
||||||
s.boolean({ title: "Boolean" }),
|
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||||
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
|
||||||
@@ -111,7 +103,7 @@ export abstract class Field<
|
|||||||
return this.config?.default_value;
|
return this.config?.default_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFillable(context?: "create" | "update"): boolean {
|
isFillable(context?: TActionContext): 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;
|
||||||
}
|
}
|
||||||
@@ -173,7 +165,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 as any);
|
return this.isFillable(context);
|
||||||
} else if (context === "create") {
|
} else if (context === "create") {
|
||||||
return !this.isRequired();
|
return !this.isRequired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export function fieldTestSuite(
|
|||||||
const _config = {
|
const _config = {
|
||||||
..._requiredConfig,
|
..._requiredConfig,
|
||||||
required: false,
|
required: false,
|
||||||
fillable: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fieldJson(field: Field) {
|
function fieldJson(field: Field) {
|
||||||
@@ -117,7 +116,10 @@ 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({
|
||||||
@@ -148,6 +150,7 @@ 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,8 +24,7 @@ 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)
|
||||||
// @note: adding non-fillable here as it'll fail validaiton on backend
|
if (!field || field.isVirtual()) return;
|
||||||
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({ virtual: false });
|
const fields = entity.getFields(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,12 +71,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected uploadFile<T extends FileUploadedEventData>(
|
protected uploadFile<T extends FileUploadedEventData>(
|
||||||
body: BodyInit,
|
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
||||||
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 = {
|
||||||
@@ -103,22 +102,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
if (opts?.path) {
|
if (opts?.path) {
|
||||||
return this.request<T>(opts.path, opts?.query, {
|
return this.post(opts.path, body, init);
|
||||||
...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.request<T>(opts?.path ?? ["upload", name], opts?.query, {
|
return this.post<T>(opts?.path ?? ["upload", name], body, init);
|
||||||
...init,
|
|
||||||
body,
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload<T extends FileUploadedEventData>(
|
async upload<T extends FileUploadedEventData>(
|
||||||
@@ -128,7 +119,6 @@ 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") {
|
||||||
@@ -154,7 +144,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.uploadFile<T>(item as BodyInit, opts);
|
return this.uploadFile<T>(item, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadToEntity(
|
async uploadToEntity(
|
||||||
@@ -165,14 +155,11 @@ 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,7 +1,6 @@
|
|||||||
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>;
|
||||||
@@ -108,29 +107,6 @@ 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,7 +11,6 @@ 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 -->";
|
||||||
@@ -21,7 +20,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?: AppTheme;
|
theme?: "dark" | "light" | "system";
|
||||||
};
|
};
|
||||||
|
|
||||||
// @todo: add migration to remove admin path from config
|
// @todo: add migration to remove admin path from config
|
||||||
@@ -32,7 +31,7 @@ export type AdminControllerOptions = {
|
|||||||
html?: string;
|
html?: string;
|
||||||
forceDev?: boolean | { mainPath: string };
|
forceDev?: boolean | { mainPath: string };
|
||||||
debugRerenders?: boolean;
|
debugRerenders?: boolean;
|
||||||
theme?: AppTheme;
|
theme?: "dark" | "light" | "system";
|
||||||
logoReturnPath?: string;
|
logoReturnPath?: string;
|
||||||
manifest?: Manifest;
|
manifest?: Manifest;
|
||||||
};
|
};
|
||||||
@@ -123,7 +122,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.replace(/\/+$/, ""),
|
admin_basepath: this.options.adminBasepath,
|
||||||
theme: this.options.theme,
|
theme: this.options.theme,
|
||||||
logo_return_path: this.options.logoReturnPath,
|
logo_return_path: this.options.logoReturnPath,
|
||||||
};
|
};
|
||||||
@@ -210,9 +209,8 @@ 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(/* @vite-ignore */ manifestPath, {
|
manifest = await import("bknd/dist/manifest.json", {
|
||||||
with: { type: "json" },
|
with: { type: "json" },
|
||||||
}).then((res) => res.default);
|
}).then((res) => res.default);
|
||||||
}
|
}
|
||||||
@@ -306,7 +304,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.strictObject({
|
s.object({
|
||||||
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, ...rest } = c.req.valid("json");
|
const { email, code } = 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,7 +232,6 @@ 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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,858 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
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,54 +71,4 @@ 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,7 +4,6 @@ import { $console } from "bknd/utils";
|
|||||||
export type TimestampsPluginOptions = {
|
export type TimestampsPluginOptions = {
|
||||||
entities: string[];
|
entities: string[];
|
||||||
setUpdatedOnCreate?: boolean;
|
setUpdatedOnCreate?: boolean;
|
||||||
indexStrategy?: "composite" | "individual";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +19,6 @@ 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",
|
||||||
@@ -31,35 +29,19 @@ 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(
|
||||||
actualEntities.map((e) => [
|
entities
|
||||||
e,
|
.filter((e) => appEntities.includes(e))
|
||||||
entity(e, {
|
.map((e) => [
|
||||||
created_at: datetime(),
|
e,
|
||||||
updated_at: datetime(),
|
entity(e, {
|
||||||
}),
|
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,4 +9,3 @@ 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,11 +16,6 @@ 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,6 +22,7 @@ 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);
|
||||||
@@ -69,27 +70,20 @@ 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 {
|
||||||
return this.withBasePath(["settings", ...path], true);
|
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
|
||||||
|
return [base, ...path].join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
getAbsolutePath(path?: string): string {
|
getAbsolutePath(path?: string): string {
|
||||||
return this.withBasePath(path ?? [], true);
|
const { basepath } = this.options;
|
||||||
|
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 has-disabled:cursor-not-allowed w-full",
|
"flex flex-col gap-1.5 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 cursor-not-allowed",
|
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||||
!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 disabled:cursor-not-allowed",
|
"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",
|
||||||
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 disabled:cursor-not-allowed scale-150 ml-1"
|
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={handleCheck}
|
onChange={handleCheck}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
@@ -260,6 +260,7 @@ 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)}
|
||||||
@@ -293,7 +294,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 disabled:cursor-not-allowed",
|
"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",
|
||||||
"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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -71,13 +70,11 @@ 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] === "~" ? adminBasePath + _href.slice(1) : router.base + _href,
|
_href[0] === "~" ? _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("//", "/");
|
||||||
@@ -85,9 +82,8 @@ 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} href={href} />;
|
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wouterOnClick = (e: any) => {
|
const wouterOnClick = (e: any) => {
|
||||||
@@ -103,11 +99,10 @@ export function Link({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @ts-expect-error className is not typed on WouterLink
|
|
||||||
<WouterLink
|
<WouterLink
|
||||||
{...props}
|
// @ts-expect-error className is not typed on WouterLink
|
||||||
className={`${active ? "active " : ""}${className}`}
|
className={`${active ? "active " : ""}${className}`}
|
||||||
href={`~${href}`}
|
{...props}
|
||||||
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={1} />
|
<Password name="password" required minLength={8} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -474,7 +474,6 @@ 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 = ({
|
||||||
@@ -484,7 +483,6 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
ActiveIcon = IconChevronUp,
|
ActiveIcon = IconChevronUp,
|
||||||
children,
|
children,
|
||||||
renderHeaderRight,
|
renderHeaderRight,
|
||||||
scrollContainerRef,
|
|
||||||
}: SectionHeaderAccordionItemProps) => (
|
}: SectionHeaderAccordionItemProps) => (
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 49 }}
|
style={{ minHeight: 49 }}
|
||||||
@@ -495,8 +493,6 @@ 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",
|
||||||
@@ -505,12 +501,14 @@ 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 grow" />
|
<div className="flex flex-grow" />
|
||||||
{renderHeaderRight?.({ open })}
|
{renderHeaderRight?.({ open })}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
className={twMerge(
|
||||||
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
|
"overflow-y-scroll transition-all",
|
||||||
|
open ? " flex-grow" : "h-0 opacity-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -520,25 +518,14 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||||
routePattern,
|
routePattern,
|
||||||
identifier,
|
identifier,
|
||||||
renderHeaderRight,
|
|
||||||
...props
|
...props
|
||||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
|
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||||
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 (
|
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
||||||
<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 } from "@mantine/core";
|
import { SegmentedControl, Tooltip } 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,35 +24,34 @@ 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, isDebug } from "core/env";
|
import { getVersion } 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 ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
|
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
const state = {
|
const state = {
|
||||||
...options?.state,
|
...options?.state,
|
||||||
referrer: location,
|
referrer: location,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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.getFields({ virtual: true, primary: false });
|
const fields = entity.getFillableFields(action, true);
|
||||||
const options = useEntityAdminOptions(entity, action);
|
const options = useEntityAdminOptions(entity, action);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +93,10 @@ export function EntityForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!field.isFillable(action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const _key = `${entity.name}-${field.name}-${key}`;
|
const _key = `${entity.name}-${field.name}-${key}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +128,7 @@ export function EntityForm({
|
|||||||
<EntityFormField
|
<EntityFormField
|
||||||
field={field}
|
field={field}
|
||||||
fieldApi={props}
|
fieldApi={props}
|
||||||
disabled={fieldsDisabled || !field.isFillable(action)}
|
disabled={fieldsDisabled}
|
||||||
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.getFields({ virtual: true, primary: false });
|
const fields = entity.getFillableFields(action, true);
|
||||||
|
|
||||||
// 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,7 +378,11 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const Policy = ({ permission }: { permission: TPermission }) => {
|
const Policy = ({
|
||||||
|
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",
|
||||||
}));
|
}));
|
||||||
@@ -499,24 +503,22 @@ const CustomFieldWrapper = ({
|
|||||||
className: "max-w-none",
|
className: "max-w-none",
|
||||||
}}
|
}}
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
target={() => (
|
target={() =>
|
||||||
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll">
|
typeof schema.content === "object" ? (
|
||||||
{typeof schema.content === "object" ? (
|
<JsonViewer
|
||||||
<JsonViewer
|
className="w-auto max-w-120 bg-background pr-3 text-sm"
|
||||||
className="pr-3 text-sm"
|
json={schema.content}
|
||||||
json={schema.content}
|
title={schema.name}
|
||||||
title={schema.name}
|
expand={5}
|
||||||
expand={5}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<CodePreview
|
||||||
<CodePreview
|
code={schema.content}
|
||||||
code={schema.content}
|
lang="typescript"
|
||||||
lang="typescript"
|
className="w-auto max-w-120 bg-background p-3 text-sm"
|
||||||
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,12 +15,7 @@ import { useBkndWindowContext } from "bknd/client";
|
|||||||
import ToolsRoutes from "./tools";
|
import ToolsRoutes from "./tools";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
let TestRoutes: any;
|
const TestRoutes = lazy(() => import("./test"));
|
||||||
try {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
TestRoutes = lazy(() => import("./test"));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
export function Routes({
|
export function Routes({
|
||||||
BkndWrapper,
|
BkndWrapper,
|
||||||
@@ -33,7 +28,7 @@ export function Routes({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const ctx = useBkndWindowContext();
|
const ctx = useBkndWindowContext();
|
||||||
const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, "");
|
const actualBasePath = basePath || ctx.admin_basepath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="bknd-admin" className={theme + " antialiased"}>
|
<div id="bknd-admin" className={theme + " antialiased"}>
|
||||||
@@ -48,13 +43,11 @@ export function Routes({
|
|||||||
<Route path="/" nest>
|
<Route path="/" nest>
|
||||||
<Root>
|
<Root>
|
||||||
<Switch>
|
<Switch>
|
||||||
{TestRoutes && (
|
<Route path="/test*" nest>
|
||||||
<Route path="/test*" nest>
|
<Suspense fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<TestRoutes />
|
||||||
<TestRoutes />
|
</Suspense>
|
||||||
</Suspense>
|
</Route>
|
||||||
</Route>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
|||||||
@@ -49,21 +49,19 @@ 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) {}
|
||||||
const roles = Object.keys(config.roles ?? {});
|
console.log("_s", _s);
|
||||||
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} nest />
|
<Route path="/mcp" component={ToolsMcp} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ 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;
|
||||||
|
|
||||||
@@ -28,57 +29,51 @@ export default function ToolsMcp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
|
<div className="flex flex-col flex-grow max-w-screen">
|
||||||
<div className="flex flex-col flex-grow max-w-screen">
|
<AppShell.SectionHeader>
|
||||||
<AppShell.SectionHeader>
|
<div className="flex flex-row gap-4 items-center">
|
||||||
<div className="flex flex-row gap-4 items-center">
|
<McpIcon />
|
||||||
<McpIcon />
|
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
||||||
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
|
MCP UI
|
||||||
MCP UI
|
</AppShell.SectionHeaderTitle>
|
||||||
</AppShell.SectionHeaderTitle>
|
<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="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
|
<TbWorld />
|
||||||
<TbWorld />
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
{window.location.origin + mcpPath}
|
||||||
{window.location.origin + mcpPath}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.SectionHeader>
|
|
||||||
|
|
||||||
<div className="flex grow h-full">
|
|
||||||
<AppShell.Sidebar>
|
|
||||||
<Tools.Sidebar />
|
|
||||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
|
||||||
title="Resources"
|
|
||||||
identifier="resources"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
|
|
||||||
<i>Resources</i>
|
|
||||||
</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>
|
||||||
|
</AppShell.SectionHeader>
|
||||||
|
|
||||||
|
<div className="flex h-full">
|
||||||
|
<AppShell.Sidebar>
|
||||||
|
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
|
||||||
|
<AppShell.SectionHeaderAccordionItem
|
||||||
|
title="Resources"
|
||||||
|
open={feature === "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
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</RoutePathStateProvider>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,23 @@ 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,6 +5,7 @@ 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";
|
||||||
@@ -12,18 +13,17 @@ 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() {
|
export function Sidebar({ open, toggle }) {
|
||||||
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,22 +39,15 @@ export function Sidebar() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleRefresh().then(() => {
|
handleRefresh();
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const selectedTool = scrollContainerRef.current.querySelector(".active");
|
|
||||||
if (selectedTool) {
|
|
||||||
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
<AppShell.SectionHeaderAccordionItem
|
||||||
scrollContainerRef={scrollContainerRef}
|
|
||||||
title="Tools"
|
title="Tools"
|
||||||
identifier="tools"
|
open={open}
|
||||||
renderHeaderRight={({ active }) => (
|
toggle={toggle}
|
||||||
|
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}>
|
||||||
@@ -64,7 +57,7 @@ export function Sidebar() {
|
|||||||
<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={!active || loading} onClick={handleRefresh} />
|
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -83,11 +76,12 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<AppShell.SidebarLink
|
<AppShell.SidebarLink
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className="flex flex-col items-start h-auto py-3 gap-px"
|
className={twMerge(
|
||||||
as={Link}
|
"flex flex-col items-start h-auto py-3 gap-px",
|
||||||
href={`/tools/${tool.name}`}
|
content?.name === tool.name ? "active" : "",
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
//setContent(tool);
|
setContent(tool);
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -98,34 +92,32 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
</AppShell.SectionHeaderAccordionItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Content() {
|
export function Content() {
|
||||||
const { toolName } = useParams();
|
const content = useMcpStore((state) => state.content);
|
||||||
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(tool?.inputSchema));
|
const [payload, setPayload] = useState<object>(getTemplate(content?.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 =
|
||||||
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPayload(getTemplate(tool?.inputSchema));
|
setPayload(getTemplate(content?.inputSchema));
|
||||||
setResult(null);
|
setResult(null);
|
||||||
}, [toolName]);
|
}, [content]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!tool?.name) return;
|
if (!content?.name) return;
|
||||||
const request = {
|
const request = {
|
||||||
name: tool.name,
|
name: content.name,
|
||||||
arguments: payload,
|
arguments: payload,
|
||||||
};
|
};
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -139,7 +131,7 @@ export function Content() {
|
|||||||
});
|
});
|
||||||
}, [payload]);
|
}, [payload]);
|
||||||
|
|
||||||
if (!tool) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
let readableResult = result;
|
let readableResult = result;
|
||||||
try {
|
try {
|
||||||
@@ -152,11 +144,11 @@ export function Content() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
className="flex grow flex-col min-w-0 max-w-screen"
|
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||||
key={tool.name}
|
key={content.name}
|
||||||
schema={{
|
schema={{
|
||||||
title: "InputSchema",
|
title: "InputSchema",
|
||||||
...tool?.inputSchema,
|
...content?.inputSchema,
|
||||||
}}
|
}}
|
||||||
validateOn="submit"
|
validateOn="submit"
|
||||||
initialValues={payload}
|
initialValues={payload}
|
||||||
@@ -171,12 +163,12 @@ export function Content() {
|
|||||||
right={
|
right={
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
Icon={historyVisible ? TbHistoryOff : TbHistory}
|
Icon={historyVisible ? TbHistory : TbHistoryOff}
|
||||||
onClick={() => setHistoryVisible(!historyVisible)}
|
onClick={() => setHistoryVisible(!historyVisible)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!tool?.name || isPending}
|
disabled={!content?.name || isPending}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
@@ -189,19 +181,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">{tool?.name}</span>
|
<span className="truncate">{content?.name}</span>
|
||||||
</AppShell.SectionHeaderTitle>
|
</AppShell.SectionHeaderTitle>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<div className="flex grow flex-row w-vw">
|
<div className="flex flex-grow flex-row w-vw">
|
||||||
<div
|
<div
|
||||||
className="flex grow flex-col max-w-full"
|
className="flex 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(tool)} className="flex flex-col py-4 px-5 gap-4">
|
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
||||||
<p className="text-primary/80">{tool?.description}</p>
|
<p className="text-primary/80">{content?.description}</p>
|
||||||
|
|
||||||
{hasInputSchema && <Field name="" />}
|
{hasInputSchema && <Field name="" />}
|
||||||
<JsonViewerTabs
|
<JsonViewerTabs
|
||||||
@@ -217,7 +209,7 @@ export function Content() {
|
|||||||
},
|
},
|
||||||
Result: { json: readableResult, title: "Result" },
|
Result: { json: readableResult, title: "Result" },
|
||||||
Configuration: {
|
Configuration: {
|
||||||
json: tool ?? null,
|
json: content ?? null,
|
||||||
title: "Configuration",
|
title: "Configuration",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -242,7 +234,7 @@ const History = () => {
|
|||||||
<>
|
<>
|
||||||
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
<AppShell.SectionHeader>History</AppShell.SectionHeader>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex grow flex-col p-3 gap-1">
|
<div className="flex flex-col flex-grow p-3 gap-1">
|
||||||
{history.map((item, i) => (
|
{history.map((item, i) => (
|
||||||
<JsonViewer
|
<JsonViewer
|
||||||
key={`${item.type}-${i}`}
|
key={`${item.type}-${i}`}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
declare module '*.css';
|
|
||||||
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