Compare commits

..
Author SHA1 Message Date
dswbx f5f591fc4a dev: add main inject plugin 2025-12-05 12:48:32 +01:00
dswbx 6bbc922710 feat: init dev command 2025-12-03 17:46:02 +01:00
227 changed files with 1335 additions and 8644 deletions

No files matched your search

-267
View File
@@ -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
View File
@@ -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.
-16
View File
@@ -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
View File
@@ -2,4 +2,3 @@ playwright-report
test-results test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts __test__/helper.d.ts
.env.local
+25 -35
View File
@@ -1,49 +1,44 @@
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(
});
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } }, { env: { TEST: "test" } },
); ),
["connection"],
expect(stripConnection(cfg)).toEqual({ ),
).toEqual({
config: { server: { cors: { origin: "test" } } }, 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: { mode: "db" as const }, options: {
mode: "db",
},
onBuilt: () => { onBuilt: () => {
called(); called();
expect(env).toEqual({ foo: "bar" }); expect(env).toEqual({ foo: "bar" });
@@ -52,17 +47,13 @@ describe("adapter", () => {
}, },
{ foo: "bar" }, { foo: "bar" },
); );
expect(config.connection).toEqual({ url: "test" });
expect(cfg.connection).toEqual({ url: "test" }); expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } }); expect(config.options).toEqual({ mode: "db" });
expect(cfg.options).toEqual({ mode: "db" }); await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
}); });
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp, makeApp: adapter.createFrameworkApp,
label: "framework app", label: "framework app",
@@ -73,4 +64,3 @@ describe("adapter", () => {
label: "runtime app", label: "runtime app",
}); });
}); });
});
-6
View File
@@ -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);
})
}); });
+6 -33
View File
@@ -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();
});
}); });
-23
View File
@@ -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();
});
});
+2 -2
View File
@@ -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 {
+13 -5
View File
@@ -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();
});
}); });
+1 -29
View File
@@ -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
View File
@@ -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",
+8 -106
View File
@@ -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");
@@ -27,18 +25,7 @@ const define = {
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;
@@ -96,8 +83,7 @@ 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",
@@ -125,10 +111,7 @@ async function buildApi() {
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"),
);
} }
/** /**
@@ -138,7 +121,7 @@ async function buildUi() {
const base = { const base = {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
define, define,
external: [ external: [
...external, ...external,
@@ -197,7 +180,7 @@ 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",
@@ -238,14 +221,11 @@ async function buildUiElements() {
/** /**
* Building adapters * Building adapters
*/ */
function baseConfig( function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
adapter: string,
overrides: Partial<tsup.Options> = {},
): tsup.Options {
return { return {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
entry: [`src/adapter/${adapter}/index.ts`], entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"], format: ["esm"],
platform: "neutral", platform: "neutral",
@@ -325,31 +305,11 @@ async function buildAdapters() {
platform: "node", platform: "node",
}), }),
tsup.build({
...baseConfig("tanstack-start"),
platform: "node",
}),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({
...baseConfig("nuxt"),
platform: "node",
}),
tsup.build({ tsup.build({
...baseConfig("node"), ...baseConfig("node"),
platform: "node", platform: "node",
}), }),
tsup.build({
...baseConfig("solid-start"),
platform: "node",
}),
tsup.build({ tsup.build({
...baseConfig("sqlite/edge"), ...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"], entry: ["src/adapter/sqlite/edge.ts"],
@@ -372,65 +332,7 @@ async function buildAdapters() {
metafile: false, metafile: false,
external: [/^bun\:.*/], 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
View File
@@ -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
-9
View File
@@ -15,12 +15,6 @@ const configs = {
nextjs: { nextjs: {
base_path: "/admin", base_path: "/admin",
}, },
nuxt: {
base_path: "/admin",
},
"solid-start": {
base_path: "/admin",
},
astro: { astro: {
base_path: "/admin", base_path: "/admin",
}, },
@@ -30,9 +24,6 @@ const configs = {
bun: { bun: {
base_path: "", base_path: "",
}, },
"tanstack-start": {
base_path: "/admin",
},
}; };
export function getAdapterConfig(): typeof default_config { export function getAdapterConfig(): typeof default_config {
+7 -34
View File
@@ -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
View File
@@ -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);
+5
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "./nuxt.adapter";
-15
View File
@@ -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,
});
});
-30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+5 -20
View File
@@ -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,16 +225,13 @@ export class AuthController extends Controller {
}; };
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
try {
const actions = this.auth.authenticator.strategy("password").getActions();
if (actions.create) {
const schema = actions.create.schema;
mcp.tool( mcp.tool(
"auth_user_create", "auth_user_create",
{ {
description: "Create a new user", description: "Create a new user",
inputSchema: s.object({ inputSchema: s.object({
...schema.properties, email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
role: s role: s
.string({ .string({
enum: roles.length > 0 ? roles : undefined, enum: roles.length > 0 ? roles : undefined,
@@ -254,10 +245,6 @@ export class AuthController extends Controller {
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", {
-1
View File
@@ -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(
+1 -6
View File
@@ -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,7 +110,6 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
}, },
); );
if (opts.allow_register) {
hono.post( hono.post(
"/register", "/register",
describeRoute({ describeRoute({
@@ -156,7 +145,6 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
} }
}, },
); );
}
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";
+27
View File
@@ -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();
}
+108
View File
@@ -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]>;
}
@@ -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"]
}
+40
View File
@@ -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)),
};
};
+1
View File
@@ -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";
+16 -17
View File
@@ -21,7 +21,8 @@ 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 // @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";"; const sql = stmts.map((d) => d.sql).join(";\n") + ";";
@@ -31,6 +32,20 @@ export const sync: CliCommand = (program) => {
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")}`);
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
}
}
} else { } else {
if (options.out) { if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
@@ -46,22 +61,6 @@ export const sync: CliCommand = (program) => {
); );
} }
} }
}
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await seed({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
}
}
process.exit(0); process.exit(0);
}); });
}; };
-1
View File
@@ -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
View File
@@ -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;
} }
} }
+7 -15
View File
@@ -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]));
+2 -4
View File
@@ -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,
}); });
} }
+6 -14
View File
@@ -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: ["create", "update"] }), { s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
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();
} }
+5 -2
View File
@@ -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,
}, },
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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"
+4 -17
View File
@@ -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,
}); });
} }
-24
View File
@@ -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;
+4 -6
View File
@@ -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);
} }
+2 -3
View File
@@ -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),
}); });
-858
View File
@@ -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);
});
});
-423
View File
@@ -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);
}
});
}); });
+3 -21
View File
@@ -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,11 +29,12 @@ 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
.filter((e) => appEntities.includes(e))
.map((e) => [
e, e,
entity(e, { entity(e, {
created_at: datetime(), created_at: datetime(),
@@ -43,23 +42,6 @@ export function timestamps({
}), }),
]), ]),
), ),
(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 () => {
-1
View File
@@ -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";
-5
View File
@@ -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 `/`
+1 -1
View File
@@ -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: "/",
} }
); );
+6 -12
View File
@@ -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,
+5 -10
View File
@@ -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}
/> />
); );
+1 -1
View File
@@ -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
+7 -20
View File
@@ -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">) => (
+12 -13
View File
@@ -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),
); );
+1 -1
View File
@@ -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
View File
@@ -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,11 +503,10 @@ 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="pr-3 text-sm" className="w-auto max-w-120 bg-background pr-3 text-sm"
json={schema.content} json={schema.content}
title={schema.name} title={schema.name}
expand={5} expand={5}
@@ -512,11 +515,10 @@ const CustomFieldWrapper = ({
<CodePreview <CodePreview
code={schema.content} code={schema.content}
lang="typescript" lang="typescript"
className="p-3 text-sm" className="w-auto max-w-120 bg-background 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)}
+2 -9
View File
@@ -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;
+1 -1
View File
@@ -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} />
</> </>
); );
} }
+13 -18
View File
@@ -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,7 +29,6 @@ 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">
@@ -47,26 +47,23 @@ export default function ToolsMcp() {
</div> </div>
</AppShell.SectionHeader> </AppShell.SectionHeader>
<div className="flex grow h-full"> <div className="flex h-full">
<AppShell.Sidebar> <AppShell.Sidebar>
<Tools.Sidebar /> <Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
<AppShell.RouteAwareSectionHeaderAccordionItem <AppShell.SectionHeaderAccordionItem
title="Resources" title="Resources"
identifier="resources" open={feature === "resources"}
toggle={() => setFeature("resources")}
> >
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40"> <div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
<i>Resources</i> <i>Resources</i>
</div> </div>
</AppShell.RouteAwareSectionHeaderAccordionItem> </AppShell.SectionHeaderAccordionItem>
</AppShell.Sidebar> </AppShell.Sidebar>
{feature === "tools" && <Tools.Content />}
<Switch> {!content && (
<Route path="/tools/:toolName?" component={Tools.Content} /> <Empty title="No tool selected" description="Please select a tool to continue.">
<Route path="*">
<Empty
title="No tool selected"
description="Please select a tool to continue."
>
<Button <Button
variant="primary" variant="primary"
onClick={() => openSidebar()} onClick={() => openSidebar()}
@@ -75,10 +72,8 @@ export default function ToolsMcp() {
Open Tools Open Tools
</Button> </Button>
</Empty> </Empty>
</Route> )}
</Switch>
</div> </div>
</div> </div>
</RoutePathStateProvider>
); );
} }
+7
View File
@@ -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)],
+36 -44
View File
@@ -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
View File
@@ -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