mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c3d3d763e | |||
| 407d103a5f | |||
| 2fd8b9f9a0 |
@@ -9,21 +9,6 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_DB: bknd
|
|
||||||
ports:
|
|
||||||
- 5430:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -39,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: bun install #--linker=hoisted
|
run: bun install
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
-267
@@ -1,267 +0,0 @@
|
|||||||
# Contributing to bknd
|
|
||||||
|
|
||||||
Thank you for your interest in contributing to bknd. This guide will help you get started, understand the codebase, and submit contributions that align with the project's direction.
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Before You Start](#before-you-start)
|
|
||||||
- [Important Resources](#important-resources)
|
|
||||||
- [Development Setup](#development-setup)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Understanding the Codebase](#understanding-the-codebase)
|
|
||||||
- [Running Tests](#running-tests)
|
|
||||||
- [Code Style](#code-style)
|
|
||||||
- [Submitting Changes](#submitting-changes)
|
|
||||||
- [AI-Generated Code](#ai-generated-code)
|
|
||||||
- [Contributing to Documentation](#contributing-to-documentation)
|
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
|
||||||
- [Getting Help](#getting-help)
|
|
||||||
- [Contributors](#contributors)
|
|
||||||
- [Maintainers](#maintainers)
|
|
||||||
|
|
||||||
## Before You Start
|
|
||||||
|
|
||||||
**Open a GitHub Issue before writing code.** This is the preferred way to propose any change. It lets maintainers and the community align on the approach before time is spent on implementation. If you have discussed a contribution in the [Discord server](https://discord.com/invite/952SFk8Tb8) instead, include a link to the relevant Discord thread in your pull request. Pull requests submitted without a corresponding issue or Discord discussion may be closed.
|
|
||||||
|
|
||||||
**Unsolicited architectural changes will be closed.** The internal architecture of bknd is intentional. Refactors, restructuring, or changes to core patterns must be discussed and approved before any code is written.
|
|
||||||
|
|
||||||
Contributions that are generally welcome (after opening an issue):
|
|
||||||
|
|
||||||
- Bug fixes
|
|
||||||
- New adapters, strategies, or storage backends
|
|
||||||
- Documentation improvements
|
|
||||||
- New examples
|
|
||||||
- Test coverage improvements
|
|
||||||
|
|
||||||
**A note on versioning**: bknd is pre-1.0 and under active development. Full backward compatibility is not guaranteed before v1.0.0. Contributors should be aware that APIs and internal interfaces may change between releases.
|
|
||||||
|
|
||||||
## Important Resources
|
|
||||||
|
|
||||||
- **Documentation**: https://docs.bknd.io
|
|
||||||
- **Issue Tracker**: https://github.com/bknd-io/bknd/issues
|
|
||||||
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
|
||||||
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 22.13 or higher
|
|
||||||
- Bun 1.3.3
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
1. Fork the repository and clone your fork.
|
|
||||||
|
|
||||||
2. Install dependencies from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Navigate to the main app package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Copy the example environment file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
The default `.env.example` includes a file-based SQLite database (`file:.db/dev.db`). You can change `VITE_DB_URL` to `:memory:` for an in-memory database instead.
|
|
||||||
|
|
||||||
5. Start the dev server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The dev server runs on `http://localhost:28623` using Vite with Hono.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
This is a Bun monorepo using workspaces. The vast majority of the code lives in `app/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
bknd/
|
|
||||||
app/ # Main "bknd" npm package (this is where most work happens)
|
|
||||||
src/
|
|
||||||
adapter/ # Runtime/framework adapters (node, bun, cloudflare, nextjs, astro, etc.)
|
|
||||||
auth/ # Authentication module (strategies, sessions, user pool)
|
|
||||||
cli/ # CLI commands
|
|
||||||
core/ # Shared utilities, event system, drivers, server helpers
|
|
||||||
data/ # Data module (entities, fields, relations, queries, schema)
|
|
||||||
flows/ # Workflow engine and tasks
|
|
||||||
media/ # Media module (storage adapters, uploads)
|
|
||||||
modes/ # Configuration modes (code, hybrid, db)
|
|
||||||
modules/ # Module system, MCP server, permissions framework
|
|
||||||
plugins/ # Built-in plugins (auth, data, cloudflare, dev)
|
|
||||||
ui/ # All frontend code
|
|
||||||
client/ # TypeScript SDK and React hooks (exported as bknd/client)
|
|
||||||
elements/ # React components for auth/media (exported as bknd/elements)
|
|
||||||
(everything else) # Admin UI (exported as bknd/ui)
|
|
||||||
App.ts # Central application orchestrator
|
|
||||||
index.ts # Main package exports
|
|
||||||
__test__/ # Unit tests (mirrors src/ structure)
|
|
||||||
e2e/ # End-to-end tests (Playwright)
|
|
||||||
build.ts # Build script (tsup/esbuild)
|
|
||||||
build.cli.ts # CLI build script
|
|
||||||
packages/ # Small satellite packages
|
|
||||||
cli/ # Standalone CLI package (bknd-cli)
|
|
||||||
plasmic/ # Plasmic integration
|
|
||||||
postgres/ # Postgres helper
|
|
||||||
sqlocal/ # SQLocal helper
|
|
||||||
docs/ # Documentation site (Next.js + Fumadocs, deployed to docs.bknd.io)
|
|
||||||
examples/ # Example projects across runtimes and frameworks
|
|
||||||
docker/ # Docker configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Understanding the Codebase
|
|
||||||
|
|
||||||
### Path Aliases
|
|
||||||
|
|
||||||
The project uses TypeScript path aliases defined in `app/tsconfig.json`. Imports like `core/utils`, `data/connection`, or `auth/authenticate` resolve to directories under `app/src/`. When reading source code, keep this in mind -- an import like `import { Connection } from "data/connection"` refers to `app/src/data/connection`, not an external package.
|
|
||||||
|
|
||||||
### Module System
|
|
||||||
|
|
||||||
bknd is built around four core modules, each with its own schema, API routes, and permissions:
|
|
||||||
|
|
||||||
- **Data** -- entity definitions, field types, relations, queries (backed by Kysely)
|
|
||||||
- **Auth** -- authentication strategies, sessions, user management
|
|
||||||
- **Media** -- file storage with pluggable adapters (S3, Cloudinary, local filesystem, etc.)
|
|
||||||
- **Flows** -- workflow automation and task execution
|
|
||||||
|
|
||||||
These modules are managed by the `ModuleManager` (in `app/src/modules/`), which handles configuration, building, and lifecycle.
|
|
||||||
|
|
||||||
### Adapter Pattern
|
|
||||||
|
|
||||||
Adapters in `app/src/adapter/` allow bknd to run on different runtimes and frameworks. Each adapter provides the glue between bknd's Hono-based server and a specific environment (Node, Bun, Cloudflare Workers, Next.js, Astro, etc.).
|
|
||||||
|
|
||||||
### Plugin System
|
|
||||||
|
|
||||||
Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `beforeBuild`, `onBuilt`, `onServerInit`, and `schema`. They can add entities, register routes, and extend behavior.
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
|
|
||||||
|
|
||||||
- **API** -- the core backend
|
|
||||||
- **UI** -- the admin interface
|
|
||||||
- **Elements** -- standalone React components
|
|
||||||
- **Adapters** -- all runtime/framework adapters
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
All test commands run from the `app/` directory.
|
|
||||||
|
|
||||||
| Command | Runner | What it runs |
|
|
||||||
|---|---|---|
|
|
||||||
| `bun run test` | Bun test | Unit tests (`*.spec.ts` files) |
|
|
||||||
| `bun run test:node` | Vitest | Node-specific tests (`*.vi-test.ts`, `*.vitest.ts`) |
|
|
||||||
| `bun run test:e2e` | Playwright | End-to-end tests (`*.e2e-spec.ts` in `e2e/`) |
|
|
||||||
| `bun run types` | TypeScript | Type checking (no emit) |
|
|
||||||
|
|
||||||
CI runs both Bun and Node tests, with a Postgres 17 service for database integration tests.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
The project uses **Biome** for formatting and linting.
|
|
||||||
|
|
||||||
- 3-space indentation for JavaScript/TypeScript
|
|
||||||
- 100-character line width
|
|
||||||
- Spaces (not tabs)
|
|
||||||
|
|
||||||
To format and lint, run from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx biome format --write ./app
|
|
||||||
bunx biome lint --changed --write ./app
|
|
||||||
```
|
|
||||||
|
|
||||||
Run both before submitting a PR. CI will catch style issues, but it is better to fix them locally first.
|
|
||||||
|
|
||||||
## Submitting Changes
|
|
||||||
|
|
||||||
1. Open a GitHub Issue describing your proposed change, or link to a Discord thread where it was discussed. Wait for feedback before writing code.
|
|
||||||
2. Fork the repo and create a branch from `main`.
|
|
||||||
3. Keep changes focused and minimal. One PR per issue.
|
|
||||||
4. Add or update tests if applicable.
|
|
||||||
5. Run the test suite and linter before pushing.
|
|
||||||
6. Open a pull request against `main`. Reference the issue number in the PR description.
|
|
||||||
|
|
||||||
Expect a response from maintainers, but be patient -- this is an actively developed project and review may take some time.
|
|
||||||
|
|
||||||
## AI-Generated Code
|
|
||||||
|
|
||||||
If you use AI tools to write or assist with your code, you must:
|
|
||||||
|
|
||||||
- **Fully review all AI-generated code yourself** before submitting. You are responsible for understanding and standing behind every line in your PR.
|
|
||||||
- **Include the prompts used** in the PR description. This gives maintainers additional context for reviewing the code.
|
|
||||||
|
|
||||||
Pull requests with unreviewed AI-generated code will be closed.
|
|
||||||
|
|
||||||
## Contributing to Documentation
|
|
||||||
|
|
||||||
The documentation site lives in the `docs/` directory and is a **separate Next.js application** built with [Fumadocs](https://fumadocs.dev). It uses **npm** (not Bun) as its package manager.
|
|
||||||
|
|
||||||
### Docs Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd docs
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The docs dev server runs on `http://localhost:3000`.
|
|
||||||
|
|
||||||
### Where Documentation Lives
|
|
||||||
|
|
||||||
Documentation content is written in MDX and located in `docs/content/docs/`. The directory is organized into:
|
|
||||||
|
|
||||||
- `(documentation)/` -- core documentation pages
|
|
||||||
- `guide/` -- guides and tutorials
|
|
||||||
- `api-reference/` -- API reference (partially auto-generated from OpenAPI)
|
|
||||||
|
|
||||||
To add or edit a page, create or modify an `.mdx` file in the appropriate directory. Page metadata is defined via frontmatter at the top of each file. Navigation order is controlled by `meta.json` files in each directory.
|
|
||||||
|
|
||||||
### Building the Docs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This generates the OpenAPI and MCP reference pages before building the Next.js site. Make sure the build succeeds locally before submitting a docs PR.
|
|
||||||
|
|
||||||
## Reporting Bugs
|
|
||||||
|
|
||||||
Open a GitHub Issue with:
|
|
||||||
|
|
||||||
- A clear title describing the problem.
|
|
||||||
- Steps to reproduce the bug.
|
|
||||||
- Expected behavior vs. actual behavior.
|
|
||||||
- Your environment (runtime, database, adapter, bknd version).
|
|
||||||
- Any relevant error messages or logs.
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
|
||||||
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
|
||||||
- **GitHub Issues**: https://github.com/bknd-io/bknd/issues
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
Thank you to everyone who has contributed to bknd.
|
|
||||||
|
|
||||||
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
Made with [contrib.rocks](https://contrib.rocks).
|
|
||||||
|
|
||||||
## Maintainers
|
|
||||||
|
|
||||||
- **Dennis** ([@dswbx](https://github.com/dswbx)) -- Creator and maintainer of bknd
|
|
||||||
- **Cameron Pak** ([@cameronapak](https://github.com/cameronapak)) -- Maintainer of bknd docs
|
|
||||||
+77
-169
@@ -1,202 +1,110 @@
|
|||||||
|
# Functional Source License, Version 1.1, MIT Future License
|
||||||
|
|
||||||
Apache License
|
## Abbreviation
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
FSL-1.1-MIT
|
||||||
|
|
||||||
1. Definitions.
|
## Notice
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
Copyright 2025 Dennis Senn
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
## Terms and Conditions
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
### Licensor ("We")
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
The party offering the Software under these Terms and Conditions.
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
### The Software
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
The "Software" is each version of the software that we make available under
|
||||||
transformation or translation of a Source form, including but
|
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||||
not limited to compiled object code, generated documentation,
|
Conditions with the Software.
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
### License Grant
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
Subject to your compliance with this License Grant and the Patents,
|
||||||
form, that is based on (or derived from) the Work and for which the
|
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
and redistribute the Software for any Permitted Purpose identified below.
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
### Permitted Purpose
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
means making the Software available to others in a commercial product or
|
||||||
subsequently incorporated within the Work.
|
service that:
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
1. substitutes for the Software;
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
2. substitutes for any other product or service we offer using the Software
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
that exists as of the date we make the Software available; or
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
3. offers the same or substantially similar functionality as the Software.
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
Permitted Purposes specifically include using the Software:
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
1. for your internal use and access;
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
2. for non-commercial education;
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
3. for non-commercial research; and
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
4. in connection with professional services that you provide to a licensee
|
||||||
may provide additional or different license terms and conditions
|
using the Software in accordance with these Terms and Conditions.
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
### Patents
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
patents, the license grant above includes a license under our patents. If you
|
||||||
except as required for reasonable and customary use in describing the
|
make a claim against any party that the Software infringes or contributes to
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
the infringement of any patent, then your patent license to the Software ends
|
||||||
|
immediately.
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
### Redistribution
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
the Software.
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
If you redistribute any copies, modifications or derivatives of the Software,
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
you must include a copy of or a link to these Terms and Conditions and not
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
remove any copyright notices provided in or with the Software.
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
### Disclaimer
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||||
replaced with your own identifying information. (Don't include
|
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 Dennis Senn
|
### Trademarks
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Except for displaying the License Details and identifying us as the origin of
|
||||||
you may not use this file except in compliance with the License.
|
the Software, you have no right under these Terms and Conditions to use our
|
||||||
You may obtain a copy of the License at
|
trademarks, trade names, service marks or product names.
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
## Grant of Future License
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
We hereby irrevocably grant you an additional license to use the Software under
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
the MIT license that is effective on the second anniversary of the date we make
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
the Software available. On or after that date, you may use the Software under
|
||||||
See the License for the specific language governing permissions and
|
the MIT license, in which case the following will apply:
|
||||||
limitations under the License.
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
|
|||||||
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
||||||
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||||
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
||||||
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS)
|
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
|
||||||
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
|
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
|
||||||
|
|
||||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ playwright-report
|
|||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
__test__/helper.d.ts
|
__test__/helper.d.ts
|
||||||
.env.local
|
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
|
|||||||
const res = await mockedBackend.request("/api/media/file/" + name);
|
const res = await mockedBackend.request("/api/media/file/" + name);
|
||||||
await Bun.write(path, res);
|
await Bun.write(path, res);
|
||||||
|
|
||||||
const file = Bun.file(path);
|
const file = await Bun.file(path);
|
||||||
expect(file.size).toBeGreaterThan(0);
|
expect(file.size).toBeGreaterThan(0);
|
||||||
expect(file.type).toBe("image/png");
|
expect(file.type).toBe("image/png");
|
||||||
await file.delete();
|
await file.delete();
|
||||||
@@ -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");
|
||||||
@@ -155,38 +154,15 @@ describe("MediaApi", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// upload via readable from bun
|
// upload via readable from bun
|
||||||
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
|
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
|
||||||
|
|
||||||
// upload via readable from response
|
// upload via readable from response
|
||||||
{
|
{
|
||||||
const response = (await mockedBackend.request(url)) as Response;
|
const response = (await mockedBackend.request(url)) as Response;
|
||||||
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
await matches(
|
||||||
|
await 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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
||||||
import { getDummyConnection } from "../../__test__/helper";
|
import { getDummyConnection } from "../../__test__/helper";
|
||||||
import { ModuleManager } from "modules/ModuleManager";
|
import { ModuleManager } from "modules/ModuleManager";
|
||||||
import { em, entity, text } from "data/prototype";
|
import { em, entity, text } from "data/prototype";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
async function createApp(config: CreateAppConfig = {}) {
|
async function createApp(config: CreateAppConfig = {}) {
|
||||||
const app = internalCreateApp({
|
const app = internalCreateApp({
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { AppEvents } from "App";
|
import { AppEvents } from "App";
|
||||||
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test";
|
import { describe, test, expect, beforeAll, mock } from "bun:test";
|
||||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
import type { McpServer } from "bknd/utils";
|
import type { McpServer } from "bknd/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - [x] system_config
|
* - [x] system_config
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
||||||
import { Permission } from "auth/authorize/Permission";
|
import { Permission } from "auth/authorize/Permission";
|
||||||
import { Role, type RoleSchema } from "auth/authorize/Role";
|
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||||
import { objectTransform, s } from "bknd/utils";
|
import { objectTransform, s } from "bknd/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
function createGuard(
|
function createGuard(
|
||||||
permissionNames: string[],
|
permissionNames: string[],
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
|
|||||||
import type { CreateUserPayload } from "auth/AppAuth";
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(enableConsoleLog);
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { createAuthTestApp } from "./shared";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
import { em, entity, text } from "data/prototype";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
const schema = em(
|
|
||||||
{
|
|
||||||
posts: entity("posts", {
|
|
||||||
title: text(),
|
|
||||||
content: text(),
|
|
||||||
}),
|
|
||||||
comments: entity("comments", {
|
|
||||||
content: text(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
({ relation }, { posts, comments }) => {
|
|
||||||
relation(posts).manyToOne(comments);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("DataController (auth)", () => {
|
|
||||||
test("reading schema.json", async () => {
|
|
||||||
const { request } = await createAuthTestApp(
|
|
||||||
{
|
|
||||||
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
|
|
||||||
request: new Request("http://localhost/api/data/schema.json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: { data: schema.toJSON() },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import type { CreateAppConfig } from "App";
|
||||||
|
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
|
||||||
|
|
||||||
|
async function makeApp(config: Partial<CreateAppConfig> = {}) {
|
||||||
|
const app = createApp(config);
|
||||||
|
await app.build();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skip("SystemController", () => {
|
||||||
|
it("...", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
const controller = new SystemController(app);
|
||||||
|
const hono = controller.getController();
|
||||||
|
console.log(getPermissionRoutes(hono));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { createAuthTestApp } from "./shared";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("SystemController (auth)", () => {
|
|
||||||
test("reading info", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.info"],
|
|
||||||
request: new Request("http://localhost/api/system/info"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reading permissions", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.schema.read"],
|
|
||||||
request: new Request("http://localhost/api/system/permissions"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("access openapi", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.openapi"],
|
|
||||||
request: new Request("http://localhost/api/system/openapi.json"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { createApp } from "core/test/utils";
|
|
||||||
import type { CreateAppConfig } from "App";
|
|
||||||
import type { RoleSchema } from "auth/authorize/Role";
|
|
||||||
import { isPlainObject } from "core/utils";
|
|
||||||
|
|
||||||
export type AuthTestConfig = {
|
|
||||||
guest?: RoleSchema;
|
|
||||||
member?: RoleSchema;
|
|
||||||
authorized?: RoleSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createAuthTestApp(
|
|
||||||
testConfig: {
|
|
||||||
permission: AuthTestConfig | string | string[];
|
|
||||||
request: Request;
|
|
||||||
},
|
|
||||||
config: Partial<CreateAppConfig> = {},
|
|
||||||
) {
|
|
||||||
let member: RoleSchema | undefined;
|
|
||||||
let authorized: RoleSchema | undefined;
|
|
||||||
let guest: RoleSchema | undefined;
|
|
||||||
if (isPlainObject(testConfig.permission)) {
|
|
||||||
if (testConfig.permission.guest)
|
|
||||||
guest = {
|
|
||||||
...testConfig.permission.guest,
|
|
||||||
is_default: true,
|
|
||||||
};
|
|
||||||
if (testConfig.permission.member) member = testConfig.permission.member;
|
|
||||||
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
|
|
||||||
} else {
|
|
||||||
member = {
|
|
||||||
permissions: [],
|
|
||||||
};
|
|
||||||
authorized = {
|
|
||||||
permissions: Array.isArray(testConfig.permission)
|
|
||||||
? testConfig.permission
|
|
||||||
: [testConfig.permission],
|
|
||||||
};
|
|
||||||
guest = {
|
|
||||||
permissions: [],
|
|
||||||
is_default: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("authorized", authorized);
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
...config,
|
|
||||||
config: {
|
|
||||||
...config.config,
|
|
||||||
auth: {
|
|
||||||
...config.config?.auth,
|
|
||||||
enabled: true,
|
|
||||||
guard: {
|
|
||||||
enabled: true,
|
|
||||||
...config.config?.auth?.guard,
|
|
||||||
},
|
|
||||||
jwt: {
|
|
||||||
...config.config?.auth?.jwt,
|
|
||||||
secret: "secret",
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
...config.config?.auth?.roles,
|
|
||||||
guest,
|
|
||||||
member,
|
|
||||||
authorized,
|
|
||||||
admin: {
|
|
||||||
implicit_allow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const users = {
|
|
||||||
guest: null,
|
|
||||||
member: await app.createUser({
|
|
||||||
email: "member@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "member",
|
|
||||||
}),
|
|
||||||
authorized: await app.createUser({
|
|
||||||
email: "authorized@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "authorized",
|
|
||||||
}),
|
|
||||||
admin: await app.createUser({
|
|
||||||
email: "admin@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "admin",
|
|
||||||
}),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tokens = {} as Record<keyof typeof users, string>;
|
|
||||||
for (const [key, user] of Object.entries(users)) {
|
|
||||||
if (user) {
|
|
||||||
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
|
|
||||||
const headers = new Headers(init.headers ?? {});
|
|
||||||
if (user in tokens) {
|
|
||||||
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
|
|
||||||
}
|
|
||||||
const res = await app.server.request(input, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: any;
|
|
||||||
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
|
|
||||||
data = await res.json();
|
|
||||||
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
|
|
||||||
data = await res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: res.status,
|
|
||||||
ok: res.ok,
|
|
||||||
headers: Object.fromEntries(res.headers.entries()),
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestFn = new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get(_, prop: keyof typeof users) {
|
|
||||||
return async (input: string, init: RequestInit = {}) => {
|
|
||||||
return makeRequest(prop, input, init);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
) as {
|
|
||||||
[K in keyof typeof users]: (
|
|
||||||
input: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<{
|
|
||||||
status: number;
|
|
||||||
ok: boolean;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
data: any;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const request = new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get(_, prop: keyof typeof users) {
|
|
||||||
return async () => {
|
|
||||||
return makeRequest(prop, testConfig.request.url, {
|
|
||||||
headers: testConfig.request.headers,
|
|
||||||
method: testConfig.request.method,
|
|
||||||
body: testConfig.request.body,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
) as {
|
|
||||||
[K in keyof typeof users]: () => Promise<{
|
|
||||||
status: number;
|
|
||||||
ok: boolean;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
data: any;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { app, users, request, requestFn };
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, beforeAll, afterAll, test } from "bun:test";
|
|
||||||
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
|
|
||||||
import { pg, postgresJs } from "bknd";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
import postgres from "postgres";
|
|
||||||
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
|
|
||||||
import { $ } from "bun";
|
|
||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
|
|
||||||
const credentials = {
|
|
||||||
host: "localhost",
|
|
||||||
port: 5430,
|
|
||||||
user: "postgres",
|
|
||||||
password: "postgres",
|
|
||||||
database: "bknd",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
|
|
||||||
const kysely = connection.kysely;
|
|
||||||
|
|
||||||
// drop all tables+indexes & create new schema
|
|
||||||
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
|
|
||||||
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
|
|
||||||
await kysely.schema.createSchema("public").execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isPostgresRunning() {
|
|
||||||
try {
|
|
||||||
// Try to actually connect to PostgreSQL
|
|
||||||
const conn = pg({ pool: new Pool(credentials) });
|
|
||||||
await conn.ping();
|
|
||||||
await conn.close();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("postgres", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
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 $waitUntil("Postgres is running", isPostgresRunning);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
disableConsoleLog();
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
if (await isPostgresRunning()) {
|
|
||||||
try {
|
|
||||||
await $`docker stop bknd-test-postgres`;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
enableConsoleLog();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.serial.each([
|
|
||||||
["pg", () => pg({ pool: new Pool(credentials) })],
|
|
||||||
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
|
|
||||||
])("%s", (name, createConnection) => {
|
|
||||||
connectionTestSuite(
|
|
||||||
{
|
|
||||||
...bunTestRunner,
|
|
||||||
test: test.serial,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
makeConnection: () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
return {
|
|
||||||
connection,
|
|
||||||
dispose: async () => {
|
|
||||||
await cleanDatabase(connection);
|
|
||||||
await connection.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
rawDialectDetails: [],
|
|
||||||
disableConsoleLog: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -124,81 +124,6 @@ describe("[Repository]", async () => {
|
|||||||
.then((r) => [r.count, r.total]),
|
.then((r) => [r.count, r.total]),
|
||||||
).resolves.toEqual([undefined, undefined]);
|
).resolves.toEqual([undefined, undefined]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto join", async () => {
|
|
||||||
const schema = $em(
|
|
||||||
{
|
|
||||||
posts: $entity("posts", {
|
|
||||||
title: $text(),
|
|
||||||
content: $text(),
|
|
||||||
}),
|
|
||||||
comments: $entity("comments", {
|
|
||||||
content: $text(),
|
|
||||||
}),
|
|
||||||
another: $entity("another", {
|
|
||||||
title: $text(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
({ relation }, { posts, comments }) => {
|
|
||||||
relation(comments).manyToOne(posts);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
|
|
||||||
await em.schema().sync({ force: true });
|
|
||||||
|
|
||||||
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
|
|
||||||
await em
|
|
||||||
.mutator("comments")
|
|
||||||
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
|
|
||||||
|
|
||||||
const res = await em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"posts.title": "post1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.data as any).toEqual([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
content: "comment1",
|
|
||||||
posts_id: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
{
|
|
||||||
// manual join should still work
|
|
||||||
const res = await em.repo("comments").findMany({
|
|
||||||
join: ["posts"],
|
|
||||||
where: {
|
|
||||||
"posts.title": "post1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.data as any).toEqual([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
content: "comment1",
|
|
||||||
posts_id: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// inexistent should be detected and thrown
|
|
||||||
expect(
|
|
||||||
em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"random.title": "post1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Invalid where field/);
|
|
||||||
|
|
||||||
// existing alias, but not a relation should throw
|
|
||||||
expect(
|
|
||||||
em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"another.title": "post1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Invalid where field/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("[data] Repository (Events)", async () => {
|
describe("[data] Repository (Events)", async () => {
|
||||||
|
|||||||
@@ -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""`;
|
|
||||||
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,10 +8,9 @@ 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();
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
});
|
});
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ beforeAll(disableConsoleLog);
|
|||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("AppAuth", () => {
|
describe("AppAuth", () => {
|
||||||
|
test.skip("...", () => {
|
||||||
|
const auth = new AppAuth({});
|
||||||
|
console.log(auth.toJSON());
|
||||||
|
console.log(auth.config);
|
||||||
|
});
|
||||||
|
|
||||||
moduleTestSuite(AppAuth);
|
moduleTestSuite(AppAuth);
|
||||||
|
|
||||||
let ctx: ModuleBuildContext;
|
let ctx: ModuleBuildContext;
|
||||||
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
|
|||||||
await auth.build();
|
await auth.build();
|
||||||
|
|
||||||
const oldConfig = auth.toJSON(true);
|
const oldConfig = auth.toJSON(true);
|
||||||
|
//console.log(oldConfig);
|
||||||
await auth.schema().patch("enabled", true);
|
await auth.schema().patch("enabled", true);
|
||||||
await auth.build();
|
await auth.build();
|
||||||
const newConfig = auth.toJSON(true);
|
const newConfig = auth.toJSON(true);
|
||||||
|
//console.log(newConfig);
|
||||||
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
disableConsoleLog();
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
enableConsoleLog();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
disableConsoleLog();
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
enableConsoleLog();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
@@ -223,32 +235,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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp } from "core/test/utils";
|
import { createApp } from "core/test/utils";
|
||||||
import { em, entity, text } from "data/prototype";
|
import { em, entity, text } from "data/prototype";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/media/AppMedia";
|
import { AppMedia } from "../../src/media/AppMedia";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("AppMedia", () => {
|
describe("AppMedia", () => {
|
||||||
test.skip("...", () => {
|
test.skip("...", () => {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { it, expect, describe, beforeAll, afterAll } from "bun:test";
|
import { it, expect, describe } from "bun:test";
|
||||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { TABLE_NAME } from "modules/db/migrations";
|
import { TABLE_NAME } from "modules/db/migrations";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("DbModuleManager", () => {
|
describe("DbModuleManager", () => {
|
||||||
it("should extract secrets", async () => {
|
it("should extract secrets", async () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
|
|||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import { entity, text } from "data/prototype";
|
import { entity, text } from "data/prototype";
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
describe("ModuleManager", async () => {
|
||||||
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const db = dummyConnection.kysely;
|
const db = dummyConnection.kysely;
|
||||||
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
|
|||||||
|
|
||||||
await mm2.build();
|
await mm2.build();
|
||||||
|
|
||||||
|
/* console.log({
|
||||||
|
json,
|
||||||
|
configs: mm2.configs(),
|
||||||
|
}); */
|
||||||
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
||||||
expect(mm2.configs().data.entities?.test).toBeDefined();
|
expect(mm2.configs().data.entities?.test).toBeDefined();
|
||||||
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
||||||
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
|
|||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
await mm.build();
|
await mm.build();
|
||||||
|
console.log("==".repeat(30));
|
||||||
|
console.log("");
|
||||||
const json = mm.configs();
|
const json = mm.configs();
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
const c2 = getDummyConnection();
|
||||||
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
|
//console.log("building FailingModule", this.config);
|
||||||
if (this.config.value && this.config.value < 0) {
|
if (this.config.value && this.config.value < 0) {
|
||||||
throw new Error("value must be positive, given: " + this.config.value);
|
throw new Error("value must be positive, given: " + this.config.value);
|
||||||
}
|
}
|
||||||
@@ -288,6 +296,9 @@ describe("ModuleManager", async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
|
||||||
|
afterEach(enableConsoleLog);
|
||||||
|
|
||||||
test("it builds", async () => {
|
test("it builds", async () => {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const mm = new TestModuleManager(dummyConnection);
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
|||||||
@@ -1,288 +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_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_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_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_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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+8
-104
@@ -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,15 +180,12 @@ async function buildUiElements() {
|
|||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch: false,
|
watch,
|
||||||
define,
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"bknd",
|
|
||||||
/^bknd\/.*/,
|
|
||||||
"wouter",
|
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
@@ -238,14 +218,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",
|
||||||
@@ -288,11 +265,6 @@ async function buildAdapters() {
|
|||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
|
||||||
baseConfig("browser", {
|
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("bun", {
|
baseConfig("bun", {
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun\:.*/],
|
||||||
@@ -325,16 +297,6 @@ async function buildAdapters() {
|
|||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("tanstack-start"),
|
|
||||||
platform: "node",
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("sveltekit"),
|
|
||||||
platform: "node",
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("node"),
|
...baseConfig("node"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
@@ -362,65 +324,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(() => {});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,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 {
|
||||||
|
|||||||
+3
-27
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.20.0",
|
"version": "0.19.0",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||||
},
|
},
|
||||||
"license": "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",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"hono": "4.10.4",
|
"hono": "4.10.4",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.10.1",
|
"jsonv-ts": "0.9.3",
|
||||||
"kysely": "0.28.8",
|
"kysely": "0.28.8",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -99,7 +99,6 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/pg": "^8.15.6",
|
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
@@ -111,17 +110,14 @@
|
|||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
"kysely-postgres-js": "^2.0.0",
|
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"miniflare": "^4.20251011.2",
|
"miniflare": "^4.20251011.2",
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"postgres": "^3.4.7",
|
|
||||||
"posthog-js-lite": "^3.6.0",
|
"posthog-js-lite": "^3.6.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -129,7 +125,6 @@
|
|||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"react-json-view-lite": "^2.5.0",
|
"react-json-view-lite": "^2.5.0",
|
||||||
"sql-formatter": "^15.6.10",
|
"sql-formatter": "^15.6.10",
|
||||||
"sqlocal": "^0.16.0",
|
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -253,26 +248,11 @@
|
|||||||
"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/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",
|
||||||
"require": "./dist/adapter/aws/index.js"
|
"require": "./dist/adapter/aws/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/browser": {
|
|
||||||
"types": "./dist/types/adapter/browser/index.d.ts",
|
|
||||||
"import": "./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",
|
||||||
@@ -290,8 +270,6 @@
|
|||||||
"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/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -321,8 +299,6 @@
|
|||||||
"remix",
|
"remix",
|
||||||
"react-router",
|
"react-router",
|
||||||
"astro",
|
"astro",
|
||||||
"sveltekit",
|
|
||||||
"svelte",
|
|
||||||
"bun",
|
"bun",
|
||||||
"node"
|
"node"
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ export class Api {
|
|||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: TApiUser;
|
private user?: TApiUser;
|
||||||
private verified = false;
|
private verified = false;
|
||||||
public token_transport: "header" | "cookie" | "none" = "header";
|
private token_transport: "header" | "cookie" | "none" = "header";
|
||||||
|
|
||||||
public system!: SystemApi;
|
public system!: SystemApi;
|
||||||
public data!: DataApi;
|
public data!: DataApi;
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
lazy,
|
|
||||||
Suspense,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { checksum } from "bknd/utils";
|
|
||||||
import { App, registries, sqlocal, type BkndConfig } from "bknd";
|
|
||||||
import { Route, Router, Switch } from "wouter";
|
|
||||||
import { ClientProvider } from "bknd/client";
|
|
||||||
import { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
import type { ClientConfig, DatabasePath } from "sqlocal";
|
|
||||||
import { OpfsStorageAdapter } from "bknd/adapter/browser";
|
|
||||||
import type { BkndAdminConfig } from "bknd/ui";
|
|
||||||
|
|
||||||
const Admin = lazy(() =>
|
|
||||||
Promise.all([
|
|
||||||
import("bknd/ui"),
|
|
||||||
// @ts-ignore
|
|
||||||
import("bknd/dist/styles.css"),
|
|
||||||
]).then(([mod]) => ({
|
|
||||||
default: mod.Admin,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
function safeViewTransition(fn: () => void) {
|
|
||||||
if (document.startViewTransition) {
|
|
||||||
document.startViewTransition(fn);
|
|
||||||
} else {
|
|
||||||
fn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
|
|
||||||
BkndConfig<Args>,
|
|
||||||
"connection" | "app"
|
|
||||||
> & {
|
|
||||||
adminConfig?: BkndAdminConfig;
|
|
||||||
connection?: ClientConfig | DatabasePath;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BkndBrowserAppProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
header?: ReactNode;
|
|
||||||
loading?: ReactNode;
|
|
||||||
notFound?: ReactNode;
|
|
||||||
} & BrowserBkndConfig;
|
|
||||||
|
|
||||||
const BkndBrowserAppContext = createContext<{
|
|
||||||
app: App;
|
|
||||||
hash: string;
|
|
||||||
}>(undefined!);
|
|
||||||
|
|
||||||
export function BkndBrowserApp({
|
|
||||||
children,
|
|
||||||
adminConfig,
|
|
||||||
header,
|
|
||||||
loading,
|
|
||||||
notFound,
|
|
||||||
...config
|
|
||||||
}: BkndBrowserAppProps) {
|
|
||||||
const [app, setApp] = useState<App | undefined>(undefined);
|
|
||||||
const [hash, setHash] = useState<string>("");
|
|
||||||
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
|
|
||||||
|
|
||||||
async function onBuilt(app: App) {
|
|
||||||
safeViewTransition(async () => {
|
|
||||||
setApp(app);
|
|
||||||
setHash(await checksum(app.toJSON()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setup({ ...config, adminConfig })
|
|
||||||
.then((app) => onBuilt(app as any))
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
return (
|
|
||||||
loading ?? (
|
|
||||||
<Center>
|
|
||||||
<span style={{ opacity: 0.2 }}>Loading...</span>
|
|
||||||
</Center>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BkndBrowserAppContext.Provider value={{ app, hash }}>
|
|
||||||
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
|
|
||||||
{header}
|
|
||||||
<Router key={hash}>
|
|
||||||
<Switch>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<Route path={adminRoutePath}>
|
|
||||||
<Suspense>
|
|
||||||
<Admin config={adminConfig} />
|
|
||||||
</Suspense>
|
|
||||||
</Route>
|
|
||||||
<Route path="*">
|
|
||||||
{notFound ?? (
|
|
||||||
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
|
|
||||||
)}
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
</Router>
|
|
||||||
</ClientProvider>
|
|
||||||
</BkndBrowserAppContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApp() {
|
|
||||||
return useContext(BkndBrowserAppContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
minHeight: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
...(props.style ?? {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
async function setup(config: BrowserBkndConfig = {}) {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
registries.media.register("opfs", OpfsStorageAdapter);
|
|
||||||
|
|
||||||
const app = App.create({
|
|
||||||
...config,
|
|
||||||
// @ts-ignore
|
|
||||||
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
|
|
||||||
});
|
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
|
||||||
await app.build({ sync: true });
|
|
||||||
await config.onBuilt?.(app);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
|
|
||||||
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
|
|
||||||
// @ts-ignore
|
|
||||||
import { assetsPath } from "../../../__test__/helper";
|
|
||||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
|
|
||||||
|
|
||||||
describe("OpfsStorageAdapter", async () => {
|
|
||||||
let mockRoot: MockFileSystemDirectoryHandle;
|
|
||||||
let testSuiteAdapter: OpfsStorageAdapter;
|
|
||||||
|
|
||||||
const _mock = spyOn(global, "navigator");
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// mock navigator.storage.getDirectory()
|
|
||||||
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
|
|
||||||
const mockNavigator = {
|
|
||||||
storage: {
|
|
||||||
getDirectory: vi.fn().mockResolvedValue(mockRoot),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// @ts-ignore
|
|
||||||
_mock.mockReturnValue(mockNavigator);
|
|
||||||
testSuiteAdapter = new OpfsStorageAdapter();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
_mock.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = Bun.file(`${assetsPath}/image.png`);
|
|
||||||
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
|
|
||||||
});
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
|
|
||||||
import { StorageAdapter, guessMimeType } from "bknd";
|
|
||||||
import { parse, s, isFile, isBlob } from "bknd/utils";
|
|
||||||
|
|
||||||
export const opfsAdapterConfig = s.object(
|
|
||||||
{
|
|
||||||
root: s.string({ default: "" }).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "OPFS",
|
|
||||||
description: "Origin Private File System storage",
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage adapter for OPFS (Origin Private File System)
|
|
||||||
* Provides browser-based file storage using the File System Access API
|
|
||||||
*/
|
|
||||||
export class OpfsStorageAdapter extends StorageAdapter {
|
|
||||||
private config: OpfsAdapterConfig;
|
|
||||||
private rootPromise: Promise<FileSystemDirectoryHandle>;
|
|
||||||
|
|
||||||
constructor(config: Partial<OpfsAdapterConfig> = {}) {
|
|
||||||
super();
|
|
||||||
this.config = parse(opfsAdapterConfig, config);
|
|
||||||
this.rootPromise = this.initializeRoot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
|
|
||||||
const opfsRoot = await navigator.storage.getDirectory();
|
|
||||||
if (!this.config.root) {
|
|
||||||
return opfsRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// navigate to or create nested directory structure
|
|
||||||
const parts = this.config.root.split("/").filter(Boolean);
|
|
||||||
let current = opfsRoot;
|
|
||||||
for (const part of parts) {
|
|
||||||
current = await current.getDirectoryHandle(part, { create: true });
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSchema() {
|
|
||||||
return opfsAdapterConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): string {
|
|
||||||
return "opfs";
|
|
||||||
}
|
|
||||||
|
|
||||||
async listObjects(prefix?: string): Promise<FileListObject[]> {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const files: FileListObject[] = [];
|
|
||||||
|
|
||||||
for await (const [name, handle] of root.entries()) {
|
|
||||||
if (handle.kind === "file") {
|
|
||||||
if (!prefix || name.startsWith(prefix)) {
|
|
||||||
const file = await (handle as FileSystemFileHandle).getFile();
|
|
||||||
files.push({
|
|
||||||
key: name,
|
|
||||||
last_modified: new Date(file.lastModified),
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
|
||||||
// wrap the hex string in quotes for ETag format
|
|
||||||
return `"${hashHex}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
|
||||||
if (body === null) {
|
|
||||||
throw new Error("Body is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key, { create: true });
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
|
|
||||||
try {
|
|
||||||
let contentBuffer: ArrayBuffer;
|
|
||||||
|
|
||||||
if (isFile(body)) {
|
|
||||||
contentBuffer = await body.arrayBuffer();
|
|
||||||
await writable.write(contentBuffer);
|
|
||||||
} else if (body instanceof ReadableStream) {
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
const reader = body.getReader();
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value);
|
|
||||||
await writable.write(value);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
// compute total size and combine chunks for etag
|
|
||||||
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
||||||
const combined = new Uint8Array(totalSize);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
contentBuffer = combined.buffer;
|
|
||||||
} else if (isBlob(body)) {
|
|
||||||
contentBuffer = await (body as Blob).arrayBuffer();
|
|
||||||
await writable.write(contentBuffer);
|
|
||||||
} else {
|
|
||||||
// body is ArrayBuffer or ArrayBufferView
|
|
||||||
if (ArrayBuffer.isView(body)) {
|
|
||||||
const view = body as ArrayBufferView;
|
|
||||||
contentBuffer = view.buffer.slice(
|
|
||||||
view.byteOffset,
|
|
||||||
view.byteOffset + view.byteLength,
|
|
||||||
) as ArrayBuffer;
|
|
||||||
} else {
|
|
||||||
contentBuffer = body as ArrayBuffer;
|
|
||||||
}
|
|
||||||
await writable.write(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writable.close();
|
|
||||||
return await this.computeEtagFromArrayBuffer(contentBuffer);
|
|
||||||
} catch (error) {
|
|
||||||
await writable.abort();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteObject(key: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
await root.removeEntry(key);
|
|
||||||
} catch {
|
|
||||||
// file doesn't exist, which is fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async objectExists(key: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
await root.getFileHandle(key);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseRangeHeader(
|
|
||||||
rangeHeader: string,
|
|
||||||
fileSize: number,
|
|
||||||
): { start: number; end: number } | null {
|
|
||||||
// parse "bytes=start-end" format
|
|
||||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [, startStr, endStr] = match;
|
|
||||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
|
||||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
|
||||||
|
|
||||||
// handle suffix-byte-range-spec (e.g., "bytes=-500")
|
|
||||||
if (!startStr && endStr) {
|
|
||||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
|
||||||
end = fileSize - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate range
|
|
||||||
if (start < 0 || end >= fileSize || start > end) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key);
|
|
||||||
const file = await fileHandle.getFile();
|
|
||||||
const fileSize = file.size;
|
|
||||||
const mimeType = guessMimeType(key);
|
|
||||||
|
|
||||||
const responseHeaders = new Headers({
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
"Content-Type": mimeType || "application/octet-stream",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rangeHeader = headers.get("range");
|
|
||||||
|
|
||||||
if (rangeHeader) {
|
|
||||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
|
||||||
|
|
||||||
if (!range) {
|
|
||||||
// invalid range - return 416 Range Not Satisfiable
|
|
||||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
|
||||||
return new Response("", {
|
|
||||||
status: 416,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, end } = range;
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const chunk = arrayBuffer.slice(start, end + 1);
|
|
||||||
|
|
||||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
|
||||||
responseHeaders.set("Content-Length", chunk.byteLength.toString());
|
|
||||||
|
|
||||||
return new Response(chunk, {
|
|
||||||
status: 206, // Partial Content
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// normal request - return entire file
|
|
||||||
const content = await file.arrayBuffer();
|
|
||||||
responseHeaders.set("Content-Length", content.byteLength.toString());
|
|
||||||
|
|
||||||
return new Response(content, {
|
|
||||||
status: 200,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// handle file reading errors
|
|
||||||
return new Response("", { status: 404 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getObjectUrl(_key: string): string {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key);
|
|
||||||
const file = await fileHandle.getFile();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: guessMimeType(key) || "application/octet-stream",
|
|
||||||
size: file.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(_secrets?: boolean) {
|
|
||||||
return {
|
|
||||||
type: this.getName(),
|
|
||||||
config: this.config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./OpfsStorageAdapter";
|
|
||||||
export * from "./BkndBrowserApp";
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// mock OPFS API for testing
|
|
||||||
class MockFileSystemFileHandle {
|
|
||||||
kind: "file" = "file";
|
|
||||||
name: string;
|
|
||||||
private content: ArrayBuffer;
|
|
||||||
private lastModified: number;
|
|
||||||
|
|
||||||
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
|
|
||||||
this.name = name;
|
|
||||||
this.content = content;
|
|
||||||
this.lastModified = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFile(): Promise<File> {
|
|
||||||
return new File([this.content], this.name, {
|
|
||||||
lastModified: this.lastModified,
|
|
||||||
type: this.guessMimeType(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWritable(): Promise<FileSystemWritableFileStream> {
|
|
||||||
const handle = this;
|
|
||||||
return {
|
|
||||||
async write(data: any) {
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
handle.content = data;
|
|
||||||
} else if (ArrayBuffer.isView(data)) {
|
|
||||||
handle.content = data.buffer.slice(
|
|
||||||
data.byteOffset,
|
|
||||||
data.byteOffset + data.byteLength,
|
|
||||||
) as ArrayBuffer;
|
|
||||||
} else if (data instanceof Blob) {
|
|
||||||
handle.content = await data.arrayBuffer();
|
|
||||||
}
|
|
||||||
handle.lastModified = Date.now();
|
|
||||||
},
|
|
||||||
async close() {},
|
|
||||||
async abort() {},
|
|
||||||
async seek(_position: number) {},
|
|
||||||
async truncate(_size: number) {},
|
|
||||||
} as FileSystemWritableFileStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
private guessMimeType(): string {
|
|
||||||
const ext = this.name.split(".").pop()?.toLowerCase();
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
webp: "image/webp",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
txt: "text/plain",
|
|
||||||
json: "application/json",
|
|
||||||
pdf: "application/pdf",
|
|
||||||
};
|
|
||||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MockFileSystemDirectoryHandle {
|
|
||||||
kind: "directory" = "directory";
|
|
||||||
name: string;
|
|
||||||
private files: Map<string, MockFileSystemFileHandle> = new Map();
|
|
||||||
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
|
|
||||||
|
|
||||||
constructor(name: string = "root") {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFileHandle(
|
|
||||||
name: string,
|
|
||||||
options?: FileSystemGetFileOptions,
|
|
||||||
): Promise<FileSystemFileHandle> {
|
|
||||||
if (this.files.has(name)) {
|
|
||||||
return this.files.get(name) as any;
|
|
||||||
}
|
|
||||||
if (options?.create) {
|
|
||||||
const handle = new MockFileSystemFileHandle(name);
|
|
||||||
this.files.set(name, handle);
|
|
||||||
return handle as any;
|
|
||||||
}
|
|
||||||
throw new Error(`File not found: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDirectoryHandle(
|
|
||||||
name: string,
|
|
||||||
options?: FileSystemGetDirectoryOptions,
|
|
||||||
): Promise<FileSystemDirectoryHandle> {
|
|
||||||
if (this.directories.has(name)) {
|
|
||||||
return this.directories.get(name) as any;
|
|
||||||
}
|
|
||||||
if (options?.create) {
|
|
||||||
const handle = new MockFileSystemDirectoryHandle(name);
|
|
||||||
this.directories.set(name, handle);
|
|
||||||
return handle as any;
|
|
||||||
}
|
|
||||||
throw new Error(`Directory not found: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
|
|
||||||
this.files.delete(name);
|
|
||||||
this.directories.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
|
||||||
for (const [name, handle] of this.files) {
|
|
||||||
yield [name, handle as any];
|
|
||||||
}
|
|
||||||
for (const [name, handle] of this.directories) {
|
|
||||||
yield [name, handle as any];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async *keys(): AsyncIterableIterator<string> {
|
|
||||||
for (const name of this.files.keys()) {
|
|
||||||
yield name;
|
|
||||||
}
|
|
||||||
for (const name of this.directories.keys()) {
|
|
||||||
yield name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async *values(): AsyncIterableIterator<FileSystemHandle> {
|
|
||||||
for (const handle of this.files.values()) {
|
|
||||||
yield handle as any;
|
|
||||||
}
|
|
||||||
for (const handle of this.directories.values()) {
|
|
||||||
yield handle as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
|
|
||||||
return this.entries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe } from "vitest";
|
import { describe, beforeAll, afterAll } from "vitest";
|
||||||
import * as node from "./node.adapter";
|
import * as node from "./node.adapter";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { viTestRunner } from "adapter/node/vitest";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./sveltekit.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe } from "bun:test";
|
|
||||||
import * as sveltekit from "./sveltekit.adapter";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("sveltekit adapter", () => {
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
|
||||||
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
|
|
||||||
makeHandler: (c, a) => (request: Request) =>
|
|
||||||
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
|
||||||
|
|
||||||
type TSvelteKit = {
|
|
||||||
request: Request;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bknd app instance
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
|
||||||
*/
|
|
||||||
export async function getApp<Env>(
|
|
||||||
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
|
||||||
args: Env,
|
|
||||||
) {
|
|
||||||
return await createRuntimeApp(config, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create request handler for hooks.server.ts
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
|
|
||||||
*/
|
|
||||||
export function serve<Env>(
|
|
||||||
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
|
|
||||||
args: Env,
|
|
||||||
) {
|
|
||||||
return async (fnArgs: TSvelteKit) => {
|
|
||||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./tanstack-start.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe } from "bun:test";
|
|
||||||
import * as tanstackStart from "./tanstack-start.adapter";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import type { TanstackStartConfig } from "./tanstack-start.adapter";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("tanstack start adapter", () => {
|
|
||||||
adapterTestSuite<TanstackStartConfig>(bunTestRunner, {
|
|
||||||
makeApp: tanstackStart.getApp,
|
|
||||||
makeHandler: tanstackStart.serve,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
|
||||||
|
|
||||||
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
|
||||||
|
|
||||||
export type TanstackStartConfig<Env = TanstackStartEnv> =
|
|
||||||
FrameworkBkndConfig<Env>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bknd app instance
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables
|
|
||||||
*/
|
|
||||||
export async function getApp<Env = TanstackStartEnv>(
|
|
||||||
config: TanstackStartConfig<Env> = {},
|
|
||||||
args: Env = process.env as Env,
|
|
||||||
) {
|
|
||||||
return await createFrameworkApp(config, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create request handler for src/routes/api.$.ts
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables
|
|
||||||
*/
|
|
||||||
export function serve<Env = TanstackStartEnv>(
|
|
||||||
config: TanstackStartConfig<Env> = {},
|
|
||||||
args: Env = process.env as Env,
|
|
||||||
) {
|
|
||||||
return async (request: Request) => {
|
|
||||||
const app = await getApp(config, args);
|
|
||||||
return app.fetch(request);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-29
@@ -46,22 +46,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
to.strategies!.password!.enabled = true;
|
to.strategies!.password!.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.default_role_register && to.default_role_register?.length > 0) {
|
|
||||||
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
|
|
||||||
|
|
||||||
if (!valid_to_role) {
|
|
||||||
const msg = `Default role for registration not found: ${to.default_role_register}`;
|
|
||||||
// if changing to a new value
|
|
||||||
if (from.default_role_register !== to.default_role_register) {
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetting gracefully, since role doesn't exist anymore
|
|
||||||
$console.warn(`${msg}, resetting to undefined`);
|
|
||||||
to.default_role_register = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +82,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||||
jwt: this.config.jwt,
|
jwt: this.config.jwt,
|
||||||
cookie: this.config.cookie,
|
cookie: this.config.cookie,
|
||||||
default_role_register: this.config.default_role_register,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerEntities();
|
this.registerEntities();
|
||||||
@@ -188,20 +171,10 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser({
|
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||||
email,
|
|
||||||
password,
|
|
||||||
role,
|
|
||||||
...additional
|
|
||||||
}: CreateUserPayload): Promise<DB["users"]> {
|
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
throw new Error("Cannot create user, auth not enabled");
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
}
|
}
|
||||||
if (role) {
|
|
||||||
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
|
|
||||||
throw new Error(`Role "${role}" not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategy = "password" as const;
|
const strategy = "password" as const;
|
||||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
@@ -210,7 +183,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
const { data: created } = await mutator.insertOne({
|
const { data: created } = await mutator.insertOne({
|
||||||
...(additional as any),
|
...(additional as any),
|
||||||
role: role || this.config.default_role_register || undefined,
|
|
||||||
email,
|
email,
|
||||||
strategy,
|
strategy,
|
||||||
strategy_value,
|
strategy_value,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
InvalidSchemaError,
|
InvalidSchemaError,
|
||||||
transformObject,
|
transformObject,
|
||||||
mcpTool,
|
mcpTool,
|
||||||
$console,
|
|
||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
|
||||||
@@ -199,12 +198,7 @@ export class AuthController extends Controller {
|
|||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||||
|
|
||||||
hono.route(
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
`/${name}`,
|
|
||||||
strategy.getController(this.auth.authenticator, {
|
|
||||||
allow_register: this.auth.config.allow_register,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.registerStrategyActions(strategy, hono);
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +210,7 @@ export class AuthController extends Controller {
|
|||||||
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||||
|
|
||||||
const getUser = async (params: { id?: string | number; email?: string }) => {
|
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||||
let user: DB["users"] | undefined;
|
let user: DB["users"] | undefined = undefined;
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
const { data } = await this.userRepo.findId(params.id);
|
const { data } = await this.userRepo.findId(params.id);
|
||||||
user = data;
|
user = data;
|
||||||
@@ -231,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", {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const authConfigSchema = $object(
|
|||||||
basepath: s.string({ default: "/api/auth" }),
|
basepath: s.string({ default: "/api/auth" }),
|
||||||
entity_name: s.string({ default: "users" }),
|
entity_name: s.string({ default: "users" }),
|
||||||
allow_register: s.boolean({ default: true }).optional(),
|
allow_register: s.boolean({ default: true }).optional(),
|
||||||
default_role_register: s.string().optional(),
|
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
strategies: $record(
|
strategies: $record(
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export const jwtConfig = s.strictObject(
|
|||||||
export const authenticatorConfig = s.object({
|
export const authenticatorConfig = s.object({
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
default_role_register: s.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||||
@@ -165,13 +164,9 @@ export class Authenticator<
|
|||||||
if (!("strategy_value" in profile)) {
|
if (!("strategy_value" in profile)) {
|
||||||
throw new InvalidConditionsException("Profile must have a strategy value");
|
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||||
}
|
}
|
||||||
if ("role" in profile) {
|
|
||||||
throw new InvalidConditionsException("Role cannot be provided during registration");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.userPool.create(strategy.getName(), {
|
const user = await this.userPool.create(strategy.getName(), {
|
||||||
...profile,
|
...profile,
|
||||||
role: this.config.default_role_register,
|
|
||||||
strategy_value: profile.strategy_value,
|
strategy_value: profile.strategy_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
-1
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
|
|||||||
try {
|
try {
|
||||||
// @ts-expect-error - this is a global variable in dev
|
// @ts-expect-error - this is a global variable in dev
|
||||||
return is_toggled(__isDev);
|
return is_toggled(__isDev);
|
||||||
} catch (_e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function getFlashMessage(
|
|||||||
): { type: FlashMessageType; message: string } | undefined {
|
): { type: FlashMessageType; message: string } | undefined {
|
||||||
const flash = getCookieValue(flash_key);
|
const flash = getCookieValue(flash_key);
|
||||||
if (flash && clear) {
|
if (flash && clear) {
|
||||||
// biome-ignore lint/suspicious/noDocumentCookie: .
|
|
||||||
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
}
|
}
|
||||||
return flash ? JSON.parse(flash) : undefined;
|
return flash ? JSON.parse(flash) : undefined;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
|||||||
|
|
||||||
export function omitKeys<T extends object, K extends keyof T>(
|
export function omitKeys<T extends object, K extends keyof T>(
|
||||||
obj: T,
|
obj: T,
|
||||||
keys_: readonly K[] | K[] | string[],
|
keys_: readonly K[],
|
||||||
): Omit<T, Extract<K, keyof T>> {
|
): Omit<T, Extract<K, keyof T>> {
|
||||||
const keys = new Set(keys_ as readonly K[]);
|
const keys = new Set(keys_);
|
||||||
const result = {} as Omit<T, Extract<K, keyof T>>;
|
const result = {} as Omit<T, Extract<K, keyof T>>;
|
||||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||||
if (!keys.has(key as K)) {
|
if (!keys.has(key as K)) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { MaybePromise } from "core/types";
|
|
||||||
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,21 +93,3 @@ export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function $waitUntil(
|
|
||||||
message: string,
|
|
||||||
condition: () => MaybePromise<boolean>,
|
|
||||||
delay = 100,
|
|
||||||
maxAttempts = 10,
|
|
||||||
) {
|
|
||||||
let attempts = 0;
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
if (await condition()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,9 +96,6 @@ export class DataController extends Controller {
|
|||||||
// read entity schema
|
// read entity schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema.json",
|
"/schema.json",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
@@ -127,9 +124,6 @@ export class DataController extends Controller {
|
|||||||
// read schema
|
// read schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
@@ -167,7 +161,7 @@ export class DataController extends Controller {
|
|||||||
hono.get(
|
hono.get(
|
||||||
"/types",
|
"/types",
|
||||||
permission(SystemPermissions.schemaRead, {
|
permission(SystemPermissions.schemaRead, {
|
||||||
context: (_c) => ({ module: "data" }),
|
context: (c) => ({ module: "data" }),
|
||||||
}),
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Retrieve data typescript definitions",
|
summary: "Retrieve data typescript definitions",
|
||||||
@@ -188,9 +182,6 @@ export class DataController extends Controller {
|
|||||||
*/
|
*/
|
||||||
hono.get(
|
hono.get(
|
||||||
"/info/:entity",
|
"/info/:entity",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
type CompiledQuery,
|
type CompiledQuery,
|
||||||
type DatabaseIntrospector,
|
type DatabaseIntrospector,
|
||||||
type Dialect,
|
type Dialect,
|
||||||
|
type Expression,
|
||||||
type Kysely,
|
type Kysely,
|
||||||
type KyselyPlugin,
|
type KyselyPlugin,
|
||||||
type OnModifyForeignAction,
|
type OnModifyForeignAction,
|
||||||
type QueryResult,
|
type QueryResult,
|
||||||
|
type RawBuilder,
|
||||||
type SelectQueryBuilder,
|
type SelectQueryBuilder,
|
||||||
type SelectQueryNode,
|
type SelectQueryNode,
|
||||||
|
type Simplify,
|
||||||
sql,
|
sql,
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||||
|
|||||||
@@ -14,22 +14,19 @@ export function connectionTestSuite(
|
|||||||
{
|
{
|
||||||
makeConnection,
|
makeConnection,
|
||||||
rawDialectDetails,
|
rawDialectDetails,
|
||||||
disableConsoleLog: _disableConsoleLog = true,
|
|
||||||
}: {
|
}: {
|
||||||
makeConnection: () => MaybePromise<{
|
makeConnection: () => MaybePromise<{
|
||||||
connection: Connection;
|
connection: Connection;
|
||||||
dispose: () => MaybePromise<void>;
|
dispose: () => MaybePromise<void>;
|
||||||
}>;
|
}>;
|
||||||
rawDialectDetails: string[];
|
rawDialectDetails: string[];
|
||||||
disableConsoleLog?: boolean;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
||||||
if (_disableConsoleLog) {
|
|
||||||
beforeAll(() => disableConsoleLog());
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(() => enableConsoleLog());
|
afterAll(() => enableConsoleLog());
|
||||||
}
|
|
||||||
|
|
||||||
|
describe("base", () => {
|
||||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
ctx = await makeConnection();
|
ctx = await makeConnection();
|
||||||
@@ -38,7 +35,6 @@ export function connectionTestSuite(
|
|||||||
await ctx.dispose();
|
await ctx.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("base", async () => {
|
|
||||||
test("pings", async () => {
|
test("pings", async () => {
|
||||||
const res = await ctx.connection.ping();
|
const res = await ctx.connection.ping();
|
||||||
expect(res).toBe(true);
|
expect(res).toBe(true);
|
||||||
@@ -102,7 +98,11 @@ export function connectionTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("schema", async () => {
|
describe("schema", async () => {
|
||||||
const makeSchema = async () => {
|
const { connection, dispose } = await makeConnection();
|
||||||
|
afterAll(async () => {
|
||||||
|
await dispose();
|
||||||
|
});
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{
|
{
|
||||||
type: "integer",
|
type: "integer",
|
||||||
@@ -119,37 +119,31 @@ export function connectionTestSuite(
|
|||||||
},
|
},
|
||||||
] as const satisfies FieldSpec[];
|
] as const satisfies FieldSpec[];
|
||||||
|
|
||||||
let b = ctx.connection.kysely.schema.createTable("test");
|
let b = connection.kysely.schema.createTable("test");
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
b = b.addColumn(...ctx.connection.getFieldSchema(field));
|
b = b.addColumn(...connection.getFieldSchema(field));
|
||||||
}
|
}
|
||||||
await b.execute();
|
await b.execute();
|
||||||
|
|
||||||
// add index
|
// add index
|
||||||
await ctx.connection.kysely.schema
|
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
|
||||||
.createIndex("test_index")
|
|
||||||
.on("test")
|
|
||||||
.columns(["id"])
|
|
||||||
.execute();
|
|
||||||
};
|
|
||||||
|
|
||||||
test("executes query", async () => {
|
test("executes query", async () => {
|
||||||
await makeSchema();
|
await connection.kysely
|
||||||
await ctx.connection.kysely
|
|
||||||
.insertInto("test")
|
.insertInto("test")
|
||||||
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const expected = { id: 1, text: "test", json: { a: 1 } };
|
const expected = { id: 1, text: "test", json: { a: 1 } };
|
||||||
|
|
||||||
const qb = ctx.connection.kysely.selectFrom("test").selectAll();
|
const qb = connection.kysely.selectFrom("test").selectAll();
|
||||||
const res = await ctx.connection.executeQuery(qb);
|
const res = await connection.executeQuery(qb);
|
||||||
expect(res.rows).toEqual([expected]);
|
expect(res.rows).toEqual([expected]);
|
||||||
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
||||||
|
|
||||||
{
|
{
|
||||||
const res = await ctx.connection.executeQueries(qb, qb);
|
const res = await connection.executeQueries(qb, qb);
|
||||||
expect(res.length).toBe(2);
|
expect(res.length).toBe(2);
|
||||||
res.map((r) => {
|
res.map((r) => {
|
||||||
expect(r.rows).toEqual([expected]);
|
expect(r.rows).toEqual([expected]);
|
||||||
@@ -161,21 +155,15 @@ export function connectionTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("introspects", async () => {
|
test("introspects", async () => {
|
||||||
await makeSchema();
|
const tables = await connection.getIntrospector().getTables({
|
||||||
const tables = await ctx.connection.getIntrospector().getTables({
|
|
||||||
withInternalKyselyTables: false,
|
withInternalKyselyTables: false,
|
||||||
});
|
});
|
||||||
const clean = tables.map((t) => ({
|
const clean = tables.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
columns: t.columns
|
columns: t.columns.map((c) => ({
|
||||||
.map((c) => ({
|
|
||||||
...c,
|
...c,
|
||||||
// ignore data type
|
|
||||||
dataType: undefined,
|
dataType: undefined,
|
||||||
// ignore default value if "id"
|
})),
|
||||||
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(clean).toEqual([
|
expect(clean).toEqual([
|
||||||
@@ -188,16 +176,7 @@ export function connectionTestSuite(
|
|||||||
dataType: undefined,
|
dataType: undefined,
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: undefined,
|
|
||||||
comment: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json",
|
|
||||||
dataType: undefined,
|
|
||||||
isNullable: true,
|
|
||||||
isAutoIncrementing: false,
|
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "text",
|
name: "text",
|
||||||
@@ -205,13 +184,20 @@ export function connectionTestSuite(
|
|||||||
isNullable: true,
|
isNullable: true,
|
||||||
isAutoIncrementing: false,
|
isAutoIncrementing: false,
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
},
|
||||||
|
{
|
||||||
|
name: "json",
|
||||||
|
dataType: undefined,
|
||||||
|
isNullable: true,
|
||||||
|
isAutoIncrementing: false,
|
||||||
|
hasDefaultValue: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
|
expect(await connection.getIntrospector().getIndices()).toEqual([
|
||||||
{
|
{
|
||||||
name: "test_index",
|
name: "test_index",
|
||||||
table: "test",
|
table: "test",
|
||||||
@@ -225,7 +211,6 @@ export function connectionTestSuite(
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("integration", async () => {
|
describe("integration", async () => {
|
||||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
|
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
|
||||||
import { customIntrospector } from "../Connection";
|
|
||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
|
|
||||||
pool: Pool;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PgPostgresConnection extends PostgresConnection<Pool> {
|
|
||||||
override name = "pg";
|
|
||||||
|
|
||||||
constructor(config: PostgresDialectConfig) {
|
|
||||||
const kysely = new Kysely({
|
|
||||||
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
|
|
||||||
excludeTables: [],
|
|
||||||
}).create(config),
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
|
|
||||||
super(kysely);
|
|
||||||
this.client = config.pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async close(): Promise<void> {
|
|
||||||
await this.client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
|
|
||||||
return new PgPostgresConnection(config);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Kysely } from "kysely";
|
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
|
||||||
import { customIntrospector } from "../Connection";
|
|
||||||
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
|
|
||||||
|
|
||||||
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
|
|
||||||
override name = "postgres-js";
|
|
||||||
|
|
||||||
constructor(config: PostgresJSDialectConfig) {
|
|
||||||
const kysely = new Kysely({
|
|
||||||
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
|
|
||||||
excludeTables: [],
|
|
||||||
}).create(config),
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
|
|
||||||
super(kysely);
|
|
||||||
this.client = config.postgres;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async close(): Promise<void> {
|
|
||||||
await this.client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function postgresJs(
|
|
||||||
config: PostgresJSDialectConfig,
|
|
||||||
): PostgresJsConnection {
|
|
||||||
return new PostgresJsConnection(config);
|
|
||||||
}
|
|
||||||
@@ -13,43 +13,31 @@ import { customIntrospector } from "../Connection";
|
|||||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||||
import type { Field } from "data/fields/Field";
|
import type { Field } from "data/fields/Field";
|
||||||
|
|
||||||
|
// @todo: add pragmas
|
||||||
export type SqliteConnectionConfig<
|
export type SqliteConnectionConfig<
|
||||||
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
||||||
> = {
|
> = {
|
||||||
excludeTables?: string[];
|
excludeTables?: string[];
|
||||||
additionalPlugins?: KyselyPlugin[];
|
|
||||||
customFn?: Partial<DbFunctions>;
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
dialect: CustomDialect;
|
dialect: CustomDialect;
|
||||||
dialectArgs?: ConstructorParameters<CustomDialect>;
|
dialectArgs?: ConstructorParameters<CustomDialect>;
|
||||||
}
|
additionalPlugins?: KyselyPlugin[];
|
||||||
| {
|
customFn?: Partial<DbFunctions>;
|
||||||
kysely: Kysely<any>;
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
|
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
|
||||||
override name = "sqlite";
|
override name = "sqlite";
|
||||||
|
|
||||||
constructor(config: SqliteConnectionConfig) {
|
constructor(config: SqliteConnectionConfig) {
|
||||||
const { excludeTables, additionalPlugins } = config;
|
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
|
||||||
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
|
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
|
||||||
|
|
||||||
let kysely: Kysely<any>;
|
const kysely = new Kysely({
|
||||||
if ("dialect" in config) {
|
dialect: customIntrospector(dialect, SqliteIntrospector, {
|
||||||
kysely = new Kysely({
|
|
||||||
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
|
|
||||||
excludeTables,
|
excludeTables,
|
||||||
plugins,
|
plugins,
|
||||||
}).create(...(config.dialectArgs ?? [])),
|
}).create(...dialectArgs),
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
} else if ("kysely" in config) {
|
|
||||||
kysely = config.kysely;
|
|
||||||
} else {
|
|
||||||
throw new Error("Either dialect or kysely must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
super(
|
super(
|
||||||
kysely,
|
kysely,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
|
|||||||
dataType: col.type,
|
dataType: col.type,
|
||||||
isNullable: !col.notnull,
|
isNullable: !col.notnull,
|
||||||
isAutoIncrementing: col.name === autoIncrementCol,
|
isAutoIncrementing: col.name === autoIncrementCol,
|
||||||
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
|
hasDefaultValue: col.dflt_value != null,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe } from "bun:test";
|
|
||||||
import { SQLocalConnection } from "./SQLocalConnection";
|
|
||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
|
|
||||||
describe("SQLocalConnection", () => {
|
|
||||||
connectionTestSuite(bunTestRunner, {
|
|
||||||
makeConnection: () => ({
|
|
||||||
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
|
|
||||||
dispose: async () => {},
|
|
||||||
}),
|
|
||||||
rawDialectDetails: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Kysely, ParseJSONResultsPlugin } from "kysely";
|
|
||||||
import { SqliteConnection } from "../SqliteConnection";
|
|
||||||
import { SqliteIntrospector } from "../SqliteIntrospector";
|
|
||||||
import type { DB } from "bknd";
|
|
||||||
import type { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
|
|
||||||
const plugins = [new ParseJSONResultsPlugin()];
|
|
||||||
|
|
||||||
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
|
|
||||||
private connected: boolean = false;
|
|
||||||
|
|
||||||
constructor(client: SQLocalKysely) {
|
|
||||||
// @ts-expect-error - config is protected
|
|
||||||
client.config.onConnect = () => {
|
|
||||||
// we need to listen for the connection, it will be awaited in init()
|
|
||||||
this.connected = true;
|
|
||||||
};
|
|
||||||
super({
|
|
||||||
kysely: new Kysely<any>({
|
|
||||||
dialect: {
|
|
||||||
...client.dialect,
|
|
||||||
createIntrospector: (db: Kysely<DB>) => {
|
|
||||||
return new SqliteIntrospector(db as any, {
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins,
|
|
||||||
}) as any,
|
|
||||||
});
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async init() {
|
|
||||||
if (this.initialized) return;
|
|
||||||
let tries = 0;
|
|
||||||
while (!this.connected && tries < 100) {
|
|
||||||
tries++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
if (!this.connected) {
|
|
||||||
throw new Error("Failed to connect to SQLite database");
|
|
||||||
}
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
|
|
||||||
return new SQLocalConnection(instance);
|
|
||||||
}
|
|
||||||
@@ -103,7 +103,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
validated.with = options.with;
|
validated.with = options.with;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add explicit joins. Implicit joins are added in `where` builder
|
|
||||||
if (options.join && options.join.length > 0) {
|
if (options.join && options.join.length > 0) {
|
||||||
for (const entry of options.join) {
|
for (const entry of options.join) {
|
||||||
const related = this.em.relationOf(entity.name, entry);
|
const related = this.em.relationOf(entity.name, entry);
|
||||||
@@ -128,28 +127,12 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||||
if (field.includes(".")) {
|
if (field.includes(".")) {
|
||||||
const [alias, prop] = field.split(".") as [string, string];
|
const [alias, prop] = field.split(".") as [string, string];
|
||||||
// check aliases first (added joins)
|
if (!aliases.includes(alias)) {
|
||||||
if (aliases.includes(alias)) {
|
|
||||||
this.checkIndex(alias, prop, "where");
|
|
||||||
return !this.em.entity(alias).getField(prop);
|
|
||||||
}
|
|
||||||
// check if alias (entity) exists
|
|
||||||
if (!this.em.hasEntity(alias)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// check related fields for auto join
|
|
||||||
const related = this.em.relationOf(entity.name, alias);
|
|
||||||
if (related) {
|
|
||||||
const other = related.other(entity);
|
|
||||||
if (other.entity.getField(prop)) {
|
|
||||||
// if related field is found, add join to validated options
|
|
||||||
validated.join?.push(alias);
|
|
||||||
this.checkIndex(alias, prop, "where");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
this.checkIndex(alias, prop, "where");
|
||||||
|
return !this.em.entity(alias).getField(prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.checkIndex(entity.name, field, "where");
|
this.checkIndex(entity.name, field, "where");
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
|
|||||||
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
|
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
|
||||||
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
|
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { test, describe, expect, beforeAll, afterAll } from "bun:test";
|
import { test, describe, expect } from "bun:test";
|
||||||
import * as q from "./query";
|
import * as q from "./query";
|
||||||
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||||
import type { PrimaryFieldType } from "modules";
|
import type { PrimaryFieldType } from "modules";
|
||||||
import type { Generated } from "kysely";
|
import type { Generated } from "kysely";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||||
$parse(q.repoQuery, v, {
|
$parse(q.repoQuery, v, {
|
||||||
@@ -16,9 +15,6 @@ const decode = (input: any, output: any) => {
|
|||||||
expect(parse(input)).toEqual(output);
|
expect(parse(input)).toEqual(output);
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
|
||||||
afterAll(() => enableConsoleLog());
|
|
||||||
|
|
||||||
describe("server/query", () => {
|
describe("server/query", () => {
|
||||||
test("limit & offset", () => {
|
test("limit & offset", () => {
|
||||||
//expect(() => parse({ limit: false })).toThrow();
|
//expect(() => parse({ limit: false })).toThrow();
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ export type * from "data/entities/Entity";
|
|||||||
export type { EntityManager } from "data/entities/EntityManager";
|
export type { EntityManager } from "data/entities/EntityManager";
|
||||||
export type { SchemaManager } from "data/schema/SchemaManager";
|
export type { SchemaManager } from "data/schema/SchemaManager";
|
||||||
export type * from "data/entities";
|
export type * from "data/entities";
|
||||||
|
|
||||||
// data connection
|
|
||||||
export {
|
export {
|
||||||
BaseIntrospector,
|
BaseIntrospector,
|
||||||
Connection,
|
Connection,
|
||||||
@@ -146,32 +144,9 @@ export {
|
|||||||
type ConnQuery,
|
type ConnQuery,
|
||||||
type ConnQueryResults,
|
type ConnQueryResults,
|
||||||
} from "data/connection";
|
} from "data/connection";
|
||||||
|
|
||||||
// data sqlite
|
|
||||||
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
||||||
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
||||||
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
|
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
|
||||||
|
|
||||||
// data sqlocal
|
|
||||||
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
|
|
||||||
|
|
||||||
// data postgres
|
|
||||||
export {
|
|
||||||
pg,
|
|
||||||
PgPostgresConnection,
|
|
||||||
} from "data/connection/postgres/PgPostgresConnection";
|
|
||||||
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
|
|
||||||
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
|
|
||||||
export {
|
|
||||||
postgresJs,
|
|
||||||
PostgresJsConnection,
|
|
||||||
} from "data/connection/postgres/PostgresJsConnection";
|
|
||||||
export {
|
|
||||||
createCustomPostgresConnection,
|
|
||||||
type CustomPostgresConnection,
|
|
||||||
} from "data/connection/postgres/custom";
|
|
||||||
|
|
||||||
// data prototype
|
|
||||||
export {
|
export {
|
||||||
text,
|
text,
|
||||||
number,
|
number,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
|
|||||||
|
|
||||||
export async function adapterTestSuite(
|
export async function adapterTestSuite(
|
||||||
testRunner: TestRunner,
|
testRunner: TestRunner,
|
||||||
_adapter: StorageAdapter | (() => StorageAdapter),
|
adapter: StorageAdapter,
|
||||||
file: File | BunFile,
|
file: File | BunFile,
|
||||||
opts?: {
|
opts?: {
|
||||||
retries?: number;
|
retries?: number;
|
||||||
@@ -25,12 +25,7 @@ export async function adapterTestSuite(
|
|||||||
const _filename = randomString(10);
|
const _filename = randomString(10);
|
||||||
const filename = `${_filename}.png`;
|
const filename = `${_filename}.png`;
|
||||||
|
|
||||||
const getAdapter = (
|
|
||||||
typeof _adapter === "function" ? _adapter : () => _adapter
|
|
||||||
) as () => StorageAdapter;
|
|
||||||
|
|
||||||
await test("puts an object", async () => {
|
await test("puts an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
objects = (await adapter.listObjects()).length;
|
objects = (await adapter.listObjects()).length;
|
||||||
const result = await adapter.putObject(filename, file as unknown as File);
|
const result = await adapter.putObject(filename, file as unknown as File);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
@@ -43,7 +38,6 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("lists objects", async () => {
|
await test("lists objects", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
const length = await retry(
|
const length = await retry(
|
||||||
() => adapter.listObjects().then((res) => res.length),
|
() => adapter.listObjects().then((res) => res.length),
|
||||||
(length) => length > objects,
|
(length) => length > objects,
|
||||||
@@ -55,12 +49,10 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("file exists", async () => {
|
await test("file exists", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.objectExists(filename)).toBe(true);
|
expect(await adapter.objectExists(filename)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test("gets an object", async () => {
|
await test("gets an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, new Headers());
|
const res = await adapter.getObject(filename, new Headers());
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||||
@@ -70,7 +62,6 @@ export async function adapterTestSuite(
|
|||||||
if (options.testRange) {
|
if (options.testRange) {
|
||||||
await test("handles range request - partial content", async () => {
|
await test("handles range request - partial content", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=0-99" });
|
const headers = new Headers({ Range: "bytes=0-99" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(206); // Partial Content
|
expect(res.status).toBe(206); // Partial Content
|
||||||
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -79,7 +70,6 @@ export async function adapterTestSuite(
|
|||||||
|
|
||||||
await test("handles range request - suffix range", async () => {
|
await test("handles range request - suffix range", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=-100" });
|
const headers = new Headers({ Range: "bytes=-100" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(206); // Partial Content
|
expect(res.status).toBe(206); // Partial Content
|
||||||
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -87,7 +77,6 @@ export async function adapterTestSuite(
|
|||||||
|
|
||||||
await test("handles invalid range request", async () => {
|
await test("handles invalid range request", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=invalid" });
|
const headers = new Headers({ Range: "bytes=invalid" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(416); // Range Not Satisfiable
|
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||||
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -95,7 +84,6 @@ export async function adapterTestSuite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await test("gets object meta", async () => {
|
await test("gets object meta", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||||
type: file.type, // image/png
|
type: file.type, // image/png
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -103,7 +91,6 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("deletes an object", async () => {
|
await test("deletes an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
||||||
|
|
||||||
if (opts?.skipExistsAfterDelete !== true) {
|
if (opts?.skipExistsAfterDelete !== true) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
|
|||||||
verbosity?: Verbosity;
|
verbosity?: Verbosity;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debug_modules = env("modules_debug", false);
|
const debug_modules = env("modules_debug");
|
||||||
|
|
||||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||||
export class ModuleManagerConfigUpdateEvent<
|
export class ModuleManagerConfigUpdateEvent<
|
||||||
|
|||||||
@@ -33,5 +33,3 @@ export const schemaRead = new Permission(
|
|||||||
);
|
);
|
||||||
export const build = new Permission("system.build");
|
export const build = new Permission("system.build");
|
||||||
export const mcp = new Permission("system.mcp");
|
export const mcp = new Permission("system.mcp");
|
||||||
export const info = new Permission("system.info");
|
|
||||||
export const openapi = new Permission("system.openapi");
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import {
|
import {
|
||||||
datetimeStringLocal,
|
datetimeStringLocal,
|
||||||
@@ -357,7 +359,7 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
override getController() {
|
override getController() {
|
||||||
const { permission, auth } = this.middlewares;
|
const { permission, auth } = this.middlewares;
|
||||||
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {}));
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
this.registerConfigController(hono);
|
this.registerConfigController(hono);
|
||||||
|
|
||||||
@@ -432,9 +434,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/permissions",
|
"/permissions",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "auth" }),
|
|
||||||
}),
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get the permissions",
|
summary: "Get the permissions",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
@@ -447,7 +446,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/build",
|
"/build",
|
||||||
permission(SystemPermissions.build, {}),
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Build the app",
|
summary: "Build the app",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
@@ -478,7 +476,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/info",
|
"/info",
|
||||||
permission(SystemPermissions.info, {}),
|
|
||||||
mcpTool("system_info"),
|
mcpTool("system_info"),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get the server info",
|
summary: "Get the server info",
|
||||||
@@ -512,7 +509,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
permission(SystemPermissions.openapi, {}),
|
|
||||||
openAPISpecs(this.ctx.server, {
|
openAPISpecs(this.ctx.server, {
|
||||||
info: {
|
info: {
|
||||||
title: "bknd API",
|
title: "bknd API",
|
||||||
@@ -520,11 +516,7 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
hono.get(
|
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||||
"/swagger",
|
|
||||||
permission(SystemPermissions.openapi, {}),
|
|
||||||
swaggerUI({ url: "/api/system/openapi.json" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function emailOTP({
|
|||||||
...entityConfig,
|
...entityConfig,
|
||||||
},
|
},
|
||||||
"generated",
|
"generated",
|
||||||
) as any,
|
),
|
||||||
},
|
},
|
||||||
({ index }, schema) => {
|
({ index }, schema) => {
|
||||||
const otp = schema[entityName]!;
|
const otp = schema[entityName]!;
|
||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,858 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { sort } from "./sort.plugin";
|
||||||
|
import { em, entity, text, number } from "bknd";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("sort plugin", () => {
|
||||||
|
test("should add sort field to configured entities", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const taskEntity = app.em.entity("tasks");
|
||||||
|
expect(taskEntity).toBeDefined();
|
||||||
|
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||||
|
expect(taskEntity?.field("position")?.type).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should auto-assign sort values on insert (starting from 0)", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// insert first item
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
expect(task1.position).toBe(0);
|
||||||
|
|
||||||
|
// insert second item
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
expect(task2.position).toBe(1);
|
||||||
|
|
||||||
|
// insert third item
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
expect(task3.position).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve manually set sort values on insert", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// insert with explicit position
|
||||||
|
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
|
||||||
|
expect(task.position).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reorder items via API endpoint", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
|
||||||
|
// move task3 (position 2) to position 0
|
||||||
|
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: task3.id, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
|
||||||
|
expect(updatedTask3.position).toBe(0); // moved to position 0
|
||||||
|
expect(updatedTask1.position).toBe(1); // shifted down
|
||||||
|
expect(updatedTask2.position).toBe(2); // shifted down
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should automatically reorder when updating sort field directly", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task4 (position 3) to position 1 by updating directly
|
||||||
|
await mutator.updateOne(task4.id, { position: 1 });
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(0); // unchanged
|
||||||
|
expect(updatedTask2.position).toBe(2); // shifted down
|
||||||
|
expect(updatedTask3.position).toBe(3); // shifted down
|
||||||
|
expect(updatedTask4.position).toBe(1); // moved to position 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should automatically reorder when moving items up", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task1 (position 0) to position 2 by updating directly
|
||||||
|
await mutator.updateOne(task1.id, { position: 2 });
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(2); // moved to position 2
|
||||||
|
expect(updatedTask2.position).toBe(0); // shifted up
|
||||||
|
expect(updatedTask3.position).toBe(1); // shifted up
|
||||||
|
expect(updatedTask4.position).toBe(3); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support scoped sorting", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1
|
||||||
|
const { data: p1t1 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 1",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t2 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 2",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create tasks in project 2
|
||||||
|
const { data: p2t1 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 1",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
const { data: p2t2 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 2",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// positions should be scoped per project
|
||||||
|
expect(p1t1.position).toBe(0);
|
||||||
|
expect(p1t2.position).toBe(1);
|
||||||
|
expect(p2t1.position).toBe(0); // resets for new scope
|
||||||
|
expect(p2t2.position).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reorder only within scope", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1
|
||||||
|
const { data: p1t1 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 1",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t2 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 2",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
const { data: p1t3 } = await mutator.insertOne({
|
||||||
|
title: "P1 Task 3",
|
||||||
|
project_id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create tasks in project 2
|
||||||
|
const { data: p2t1 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 1",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
const { data: p2t2 } = await mutator.insertOne({
|
||||||
|
title: "P2 Task 2",
|
||||||
|
project_id: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// move p1t3 to position 0 (should only affect project 1)
|
||||||
|
await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: p1t3.id, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify project 1 tasks
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
|
||||||
|
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
|
||||||
|
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
|
||||||
|
|
||||||
|
expect(updatedP1t3.position).toBe(0); // moved to position 0
|
||||||
|
expect(updatedP1t1.position).toBe(1); // shifted
|
||||||
|
expect(updatedP1t2.position).toBe(2); // shifted
|
||||||
|
|
||||||
|
// verify project 2 tasks are unchanged
|
||||||
|
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
|
||||||
|
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
|
||||||
|
|
||||||
|
expect(updatedP2t1.position).toBe(0); // unchanged
|
||||||
|
expect(updatedP2t2.position).toBe(1); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should recalculate all positions", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks with irregular positions
|
||||||
|
await mutator.insertOne({ title: "Task 1", position: 5 });
|
||||||
|
await mutator.insertOne({ title: "Task 2", position: 10 });
|
||||||
|
await mutator.insertOne({ title: "Task 3", position: 15 });
|
||||||
|
await mutator.insertOne({ title: "Task 4", position: 100 });
|
||||||
|
|
||||||
|
// recalculate
|
||||||
|
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify positions are now 0, 1, 2, 3
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(4);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
expect(tasks[2].position).toBe(2);
|
||||||
|
expect(tasks[3].position).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should recalculate positions within scope only", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
project_id: number(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
scope: "project_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks in project 1 with irregular positions
|
||||||
|
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
|
||||||
|
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
|
||||||
|
|
||||||
|
// create tasks in project 2 with irregular positions
|
||||||
|
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
|
||||||
|
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
|
||||||
|
|
||||||
|
// recalculate only project 1
|
||||||
|
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ scope: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// verify project 1 tasks are recalculated
|
||||||
|
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
where: { project_id: 1 },
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1Tasks.length).toBe(2);
|
||||||
|
expect(p1Tasks[0].position).toBe(0);
|
||||||
|
expect(p1Tasks[1].position).toBe(1);
|
||||||
|
|
||||||
|
// verify project 2 tasks are unchanged
|
||||||
|
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
where: { project_id: 2 },
|
||||||
|
orderBy: [{ position: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p2Tasks.length).toBe(2);
|
||||||
|
expect(p2Tasks[0].position).toBe(10); // unchanged
|
||||||
|
expect(p2Tasks[1].position).toBe(20); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle moving items to the end", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks at positions 0, 1, 2, 3
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||||
|
|
||||||
|
// move task1 to the end (position 3)
|
||||||
|
await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: task1.id, position: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify positions
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(3); // moved to end
|
||||||
|
expect(updatedTask2.position).toBe(0); // shifted up
|
||||||
|
expect(updatedTask3.position).toBe(1); // shifted up
|
||||||
|
expect(updatedTask4.position).toBe(2); // shifted up
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid item id", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: 999999, position: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple entities with different configurations", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
categories: entity("categories", {
|
||||||
|
name: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
field: "order",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
// verify both entities have their sort fields
|
||||||
|
const taskEntity = app.em.entity("tasks");
|
||||||
|
const categoryEntity = app.em.entity("categories");
|
||||||
|
|
||||||
|
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||||
|
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
|
||||||
|
|
||||||
|
// create items in both entities
|
||||||
|
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
|
||||||
|
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
|
||||||
|
|
||||||
|
expect(task.position).toBe(0);
|
||||||
|
expect(category.order).toBe(0);
|
||||||
|
|
||||||
|
// verify both endpoints exist
|
||||||
|
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const catRes = await app.server.request("/api/sort/categories/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(taskRes.status).toBe(200);
|
||||||
|
expect(catRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not trigger reorder when updating other fields", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||||
|
|
||||||
|
// update title only (should not trigger reordering)
|
||||||
|
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
|
||||||
|
|
||||||
|
// verify positions are unchanged
|
||||||
|
const repo = app.em.repo("tasks");
|
||||||
|
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||||
|
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||||
|
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||||
|
|
||||||
|
expect(updatedTask1.position).toBe(0);
|
||||||
|
expect(updatedTask2.position).toBe(1);
|
||||||
|
expect(updatedTask2.title).toBe("Task 2 Updated");
|
||||||
|
expect(updatedTask3.position).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle null sort values", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create task with null position (bypass listener by using kysely directly)
|
||||||
|
await app.connection.kysely
|
||||||
|
.insertInto("tasks")
|
||||||
|
.values({ title: "Task with null", position: null })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// create normal task
|
||||||
|
await mutator.insertOne({ title: "Task 2" });
|
||||||
|
|
||||||
|
// update the null task to have a position
|
||||||
|
const nullTask = await app.connection.kysely
|
||||||
|
.selectFrom("tasks")
|
||||||
|
.selectAll()
|
||||||
|
.where("title", "=", "Task with null")
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
await mutator.updateOne(nullTask!.id, { position: 0 });
|
||||||
|
|
||||||
|
// verify both tasks have proper positions
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(2);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not create duplicates when moving to an occupied position", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create two tasks at positions 0 and 1
|
||||||
|
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||||
|
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||||
|
|
||||||
|
expect(task1.position).toBe(0);
|
||||||
|
expect(task2.position).toBe(1);
|
||||||
|
|
||||||
|
// move task2 (at position 1) to position 0
|
||||||
|
await mutator.updateOne(task2.id, { position: 0 });
|
||||||
|
|
||||||
|
// verify no duplicates
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks.length).toBe(2);
|
||||||
|
expect(tasks[0].id).toBe(task2.id);
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].id).toBe(task1.id);
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
|
||||||
|
// verify no tasks have the same position
|
||||||
|
const positions = tasks.map((t) => t.position);
|
||||||
|
const uniquePositions = new Set(positions);
|
||||||
|
expect(uniquePositions.size).toBe(positions.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve order when recalculating", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
data: em({
|
||||||
|
tasks: entity("tasks", {
|
||||||
|
title: text(),
|
||||||
|
}),
|
||||||
|
}).toJSON(),
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
sort({
|
||||||
|
entities: {
|
||||||
|
tasks: {
|
||||||
|
field: "position",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const mutator = app.em.mutator("tasks");
|
||||||
|
|
||||||
|
// create tasks with specific positions
|
||||||
|
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
|
||||||
|
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
|
||||||
|
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
|
||||||
|
|
||||||
|
// recalculate
|
||||||
|
await app.server.request("/api/sort/tasks/recalculate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// verify order is preserved (B, A, C based on original positions)
|
||||||
|
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||||
|
sort: { by: "position", dir: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
|
||||||
|
expect(tasks[0].position).toBe(0);
|
||||||
|
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
|
||||||
|
expect(tasks[1].position).toBe(1);
|
||||||
|
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
|
||||||
|
expect(tasks[2].position).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
|
||||||
|
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
|
||||||
|
const DEFAULT_BATCH_SIZE = 1000;
|
||||||
|
|
||||||
|
export type SortPluginOptions = {
|
||||||
|
/**
|
||||||
|
* The base path for the API endpoints.
|
||||||
|
* @default "/api/sort"
|
||||||
|
*/
|
||||||
|
apiBasePath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for entities that should have sorting enabled.
|
||||||
|
* Key is the entity name, value is the configuration.
|
||||||
|
*/
|
||||||
|
entities: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name of the sort property (must be a number field).
|
||||||
|
*/
|
||||||
|
field: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional scope field name. If provided, sorting will only happen within the same scope.
|
||||||
|
* For example, if scope is "category_id", items will only be sorted within items
|
||||||
|
* that have the same category_id value.
|
||||||
|
*/
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batch size for recalculating sort order.
|
||||||
|
* @default 1000
|
||||||
|
*/
|
||||||
|
recalculateBatchSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SortError extends Exception {
|
||||||
|
override name = "SortError";
|
||||||
|
override code = HttpStatus.BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sort({
|
||||||
|
apiBasePath = "/api/sort",
|
||||||
|
entities,
|
||||||
|
recalculateBatchSize = DEFAULT_BATCH_SIZE,
|
||||||
|
}: SortPluginOptions): AppPlugin {
|
||||||
|
return (app: App) => {
|
||||||
|
return {
|
||||||
|
name: "sort",
|
||||||
|
schema: () => {
|
||||||
|
return em(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(entities).map(([entityName, config]) => [
|
||||||
|
entityName,
|
||||||
|
entity(entityName, {
|
||||||
|
[config.field]: number({ default_value: 0 }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
({ index }, schema) => {
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
const indexed = app.em.getIndexedFields(entityName);
|
||||||
|
if (!indexed.some((f) => f.name === config.field)) {
|
||||||
|
index(schema[entityName]!).on([config.field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onBuilt: async () => {
|
||||||
|
invariant(
|
||||||
|
entities && Object.keys(entities).length > 0,
|
||||||
|
"At least one entity must be configured",
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate entities exist and have the configured fields
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
const entity = app.em.entity(entityName);
|
||||||
|
invariant(entity, `Entity "${entityName}" not found in schema`);
|
||||||
|
|
||||||
|
const sortFieldSchema = entity.field(config.field)!;
|
||||||
|
invariant(
|
||||||
|
sortFieldSchema,
|
||||||
|
`Sort field "${config.field}" not found in entity "${entityName}"`,
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
sortFieldSchema.type === "number",
|
||||||
|
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.scope) {
|
||||||
|
const scopeFieldSchema = entity.field(config.scope);
|
||||||
|
invariant(
|
||||||
|
scopeFieldSchema,
|
||||||
|
`Scope field "${config.scope}" not found in entity "${entityName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hono = new Hono();
|
||||||
|
|
||||||
|
// register recalculate endpoints for each entity
|
||||||
|
for (const [entityName, config] of Object.entries(entities)) {
|
||||||
|
hono.post(
|
||||||
|
`/${entityName}/recalculate`,
|
||||||
|
jsc(
|
||||||
|
"json",
|
||||||
|
s
|
||||||
|
.object({
|
||||||
|
scope: s.any().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const scope = body?.scope;
|
||||||
|
|
||||||
|
await recalculateSortOrder(
|
||||||
|
app,
|
||||||
|
entityName,
|
||||||
|
config,
|
||||||
|
recalculateBatchSize,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ success: true, message: "Sort order recalculated" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
hono.post(
|
||||||
|
`/${entityName}/reorder`,
|
||||||
|
jsc(
|
||||||
|
"json",
|
||||||
|
s.object({
|
||||||
|
id: s.any(),
|
||||||
|
position: s.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { id, position } = c.req.valid("json");
|
||||||
|
|
||||||
|
await reorderItem(app, entityName, config, id, position);
|
||||||
|
|
||||||
|
return c.json({ success: true, message: "Item reordered" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.server.route(apiBasePath, hono);
|
||||||
|
|
||||||
|
// register listeners for automatic reordering
|
||||||
|
registerListeners(app, entities);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderItem(
|
||||||
|
app: App,
|
||||||
|
entityName: string,
|
||||||
|
config: SortPluginOptions["entities"][string],
|
||||||
|
id: any,
|
||||||
|
newPosition: number,
|
||||||
|
) {
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const em = app.em.fork();
|
||||||
|
const kysely = em.connection.kysely;
|
||||||
|
|
||||||
|
// get the item
|
||||||
|
const { data: item } = await em.repo(entityName).findOne({ id });
|
||||||
|
if (!item) {
|
||||||
|
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPosition = item[sortField] as number | null | undefined;
|
||||||
|
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||||
|
|
||||||
|
// update the item with new position (using forked em, so no listeners are triggered)
|
||||||
|
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
|
||||||
|
|
||||||
|
// shift other items using kysely
|
||||||
|
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
|
||||||
|
if (newPosition < oldPosition) {
|
||||||
|
// moving up: increment items between newPosition and oldPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where(sortField as any, "<", oldPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
} else {
|
||||||
|
// moving down: decrement items between oldPosition and newPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||||
|
.where(sortField as any, ">", oldPosition)
|
||||||
|
.where(sortField as any, "<=", newPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
} else if (oldPosition === undefined || oldPosition === null) {
|
||||||
|
// new item, shift everything at or after this position
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where("id", "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateSortOrder(
|
||||||
|
app: App,
|
||||||
|
entityName: string,
|
||||||
|
config: SortPluginOptions["entities"][string],
|
||||||
|
batchSize: number,
|
||||||
|
scope?: any,
|
||||||
|
) {
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const db = app.connection.kysely;
|
||||||
|
|
||||||
|
const { count } = (await db
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select((eb) => eb.fn.count<number>("id").as("count"))
|
||||||
|
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scope),
|
||||||
|
)
|
||||||
|
.$castTo<{ count: number }>()
|
||||||
|
.executeTakeFirst()) ?? { count: 0 };
|
||||||
|
|
||||||
|
const batches = Math.ceil(count / batchSize);
|
||||||
|
for (let i = 0; i < batches; i++) {
|
||||||
|
// get all items in scope, ordered by current sort value
|
||||||
|
const items = await db
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select(["id", sortField])
|
||||||
|
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scope),
|
||||||
|
)
|
||||||
|
.orderBy(sortField, "asc")
|
||||||
|
.limit(batchSize)
|
||||||
|
.offset(i * batchSize)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const newQbs = items.map((item, index) =>
|
||||||
|
db
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: index })
|
||||||
|
.where("id", "=", item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.connection.executeQueries(...newQbs);
|
||||||
|
$console.log(
|
||||||
|
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
|
||||||
|
const kysely = app.connection.kysely;
|
||||||
|
|
||||||
|
// handle insert events
|
||||||
|
app.emgr.onEvent(
|
||||||
|
DatabaseEvents.MutatorInsertBefore,
|
||||||
|
async (e) => {
|
||||||
|
const entityName = e.params.entity.name;
|
||||||
|
const config = entities[entityName];
|
||||||
|
if (!config) return e.params.data;
|
||||||
|
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const data = e.params.data;
|
||||||
|
const scopeValue = scopeField ? data[scopeField] : undefined;
|
||||||
|
|
||||||
|
// if no position provided, set to max + 1
|
||||||
|
if (data[sortField] === undefined || data[sortField] === null) {
|
||||||
|
const query = kysely
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
|
||||||
|
// add scope filter if needed
|
||||||
|
.$if(Boolean(scopeField && scopeValue), (eb) =>
|
||||||
|
eb.where(scopeField as any, "=", scopeValue as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await query.executeTakeFirst();
|
||||||
|
const max = result?.max ?? -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[sortField]: max + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// if position is provided, shift other items at or after that position
|
||||||
|
const newPosition = data[sortField] as number;
|
||||||
|
|
||||||
|
let shiftQuery = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await shiftQuery.execute();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "sync",
|
||||||
|
id: "bknd-sort-insert",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle update events
|
||||||
|
app.emgr.onEvent(
|
||||||
|
DatabaseEvents.MutatorUpdateBefore,
|
||||||
|
async (e) => {
|
||||||
|
const entityName = e.params.entity.name;
|
||||||
|
const config = entities[entityName];
|
||||||
|
if (!config) return e.params.data;
|
||||||
|
|
||||||
|
const { field: sortField, scope: scopeField } = config;
|
||||||
|
const data = e.params.data;
|
||||||
|
|
||||||
|
// only handle if sort field is being updated
|
||||||
|
if (!(sortField in data)) return e.params.data;
|
||||||
|
|
||||||
|
const newPosition = data[sortField] as number;
|
||||||
|
const id = e.params.entityId;
|
||||||
|
|
||||||
|
// get the current item to know its old position and scope
|
||||||
|
const item = await kysely
|
||||||
|
.selectFrom(entityName)
|
||||||
|
.selectAll()
|
||||||
|
.where("id" as any, "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!item) return data;
|
||||||
|
|
||||||
|
const oldPosition = item[sortField] as number | null | undefined;
|
||||||
|
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||||
|
|
||||||
|
// if oldPosition is null or undefined, treat as inserting at newPosition
|
||||||
|
if (oldPosition === null || oldPosition === undefined) {
|
||||||
|
// shift items at or after the new position
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shift other items using kysely
|
||||||
|
if (oldPosition !== newPosition) {
|
||||||
|
if (newPosition < oldPosition) {
|
||||||
|
// moving up: increment items between newPosition and oldPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||||
|
.where(sortField as any, ">=", newPosition)
|
||||||
|
.where(sortField as any, "<", oldPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
} else {
|
||||||
|
// moving down: decrement items between oldPosition and newPosition
|
||||||
|
let query = kysely
|
||||||
|
.updateTable(entityName)
|
||||||
|
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||||
|
.where(sortField as any, ">", oldPosition)
|
||||||
|
.where(sortField as any, "<=", newPosition)
|
||||||
|
.where("id" as any, "!=", id);
|
||||||
|
|
||||||
|
if (scopeField && scopeValue !== undefined) {
|
||||||
|
query = query.where(scopeField as any, "=", scopeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "sync",
|
||||||
|
id: "bknd-sort-update",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,54 +71,4 @@ describe("timestamps plugin", () => {
|
|||||||
expect(updatedData.updated_at).toBeDefined();
|
expect(updatedData.updated_at).toBeDefined();
|
||||||
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("index strategy", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
posts: entity("posts", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [timestamps({ entities: ["posts"] })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
expect(app.em.indices.length).toBe(0);
|
|
||||||
{
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
posts: entity("posts", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
expect(app.em.indices.length).toBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
posts: entity("posts", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
expect(app.em.indices.length).toBe(2);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { $console } from "bknd/utils";
|
|||||||
export type TimestampsPluginOptions = {
|
export type TimestampsPluginOptions = {
|
||||||
entities: string[];
|
entities: string[];
|
||||||
setUpdatedOnCreate?: boolean;
|
setUpdatedOnCreate?: boolean;
|
||||||
indexStrategy?: "composite" | "individual";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +19,6 @@ export type TimestampsPluginOptions = {
|
|||||||
export function timestamps({
|
export function timestamps({
|
||||||
entities = [],
|
entities = [],
|
||||||
setUpdatedOnCreate = true,
|
setUpdatedOnCreate = true,
|
||||||
indexStrategy,
|
|
||||||
}: TimestampsPluginOptions): AppPlugin {
|
}: TimestampsPluginOptions): AppPlugin {
|
||||||
return (app: App) => ({
|
return (app: App) => ({
|
||||||
name: "timestamps",
|
name: "timestamps",
|
||||||
@@ -31,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 () => {
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
|||||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||||
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
||||||
|
export { sort, type SortPluginOptions } from "./data/sort.plugin";
|
||||||
|
|||||||
+15
-17
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
|
|||||||
import { useTheme, type AppTheme } from "ui/client/use-theme";
|
import { useTheme, type AppTheme } from "ui/client/use-theme";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client";
|
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
||||||
import { createMantineTheme } from "./lib/mantine/theme";
|
import { createMantineTheme } from "./lib/mantine/theme";
|
||||||
import { Routes } from "./routes";
|
import { Routes } from "./routes";
|
||||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
||||||
@@ -52,30 +52,26 @@ export type BkndAdminProps = {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Admin(props: BkndAdminProps) {
|
export default function Admin({
|
||||||
|
baseUrl: baseUrlOverride,
|
||||||
|
withProvider = false,
|
||||||
|
config: _config = {},
|
||||||
|
children,
|
||||||
|
}: BkndAdminProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const Provider = ({ children }: any) =>
|
const Provider = ({ children }: any) =>
|
||||||
props.withProvider ? (
|
withProvider ? (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
baseUrl={props.baseUrl}
|
baseUrl={baseUrlOverride}
|
||||||
{...(typeof props.withProvider === "object" ? props.withProvider : {})}
|
{...(typeof withProvider === "object" ? withProvider : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<Provider>
|
|
||||||
<AdminInner {...props} />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminInner(props: BkndAdminProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const config = {
|
const config = {
|
||||||
...props.config,
|
..._config,
|
||||||
...useBkndWindowContext(),
|
...useBkndWindowContext(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,12 +82,14 @@ function AdminInner(props: BkndAdminProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Provider>
|
||||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||||
{props.children}
|
{children}
|
||||||
</Routes>
|
</Routes>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useApi } from "bknd/client";
|
import { useApi } from "ui/client";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
import { Message } from "ui/components/display/Message";
|
import { Message } from "ui/components/display/Message";
|
||||||
|
|||||||
@@ -14,20 +14,18 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
|
|||||||
export type ClientProviderProps = {
|
export type ClientProviderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
api?: Api;
|
|
||||||
} & ApiOptions;
|
} & ApiOptions;
|
||||||
|
|
||||||
export const ClientProvider = ({
|
export const ClientProvider = ({
|
||||||
children,
|
children,
|
||||||
host,
|
host,
|
||||||
baseUrl: _baseUrl = host,
|
baseUrl: _baseUrl = host,
|
||||||
api: _api,
|
|
||||||
...props
|
...props
|
||||||
}: ClientProviderProps) => {
|
}: ClientProviderProps) => {
|
||||||
const winCtx = useBkndWindowContext();
|
const winCtx = useBkndWindowContext();
|
||||||
const _ctx = useClientContext();
|
const _ctx = useClientContext();
|
||||||
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
||||||
let user: any;
|
let user: any = undefined;
|
||||||
|
|
||||||
if (winCtx) {
|
if (winCtx) {
|
||||||
user = winCtx.user;
|
user = winCtx.user;
|
||||||
@@ -42,7 +40,6 @@ export const ClientProvider = ({
|
|||||||
const apiProps = { user, ...props, host: actualBaseUrl };
|
const apiProps = { user, ...props, host: actualBaseUrl };
|
||||||
const api = useMemo(
|
const api = useMemo(
|
||||||
() =>
|
() =>
|
||||||
_api ??
|
|
||||||
new Api({
|
new Api({
|
||||||
...apiProps,
|
...apiProps,
|
||||||
verbose: isDebug(),
|
verbose: isDebug(),
|
||||||
@@ -53,7 +50,7 @@ export const ClientProvider = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[_api, JSON.stringify(apiProps)],
|
[JSON.stringify(apiProps)],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||||
@@ -67,14 +64,9 @@ export const ClientProvider = ({
|
|||||||
|
|
||||||
export const useApi = (host?: ApiOptions["host"]): Api => {
|
export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||||
const context = useContext(ClientContext);
|
const context = useContext(ClientContext);
|
||||||
|
|
||||||
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
||||||
console.info("creating new api", { host });
|
|
||||||
return new Api({ host: host ?? "" });
|
return new Api({ host: host ?? "" });
|
||||||
}
|
}
|
||||||
if (!context) {
|
|
||||||
throw new Error("useApi must be used within a ClientProvider");
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.api;
|
return context.api;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Api } from "Api";
|
|||||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
import { useApi } from "../ClientProvider";
|
import { useApi } from "ui/client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const useApiQuery = <
|
export const useApiQuery = <
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||||
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
||||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||||
import { type Api, useApi } from "bknd/client";
|
import { type Api, useApi } from "ui/client";
|
||||||
|
|
||||||
export class UseEntityApiError<Payload = any> extends Error {
|
export class UseEntityApiError<Payload = any> extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export {
|
|||||||
type ClientProviderProps,
|
type ClientProviderProps,
|
||||||
useApi,
|
useApi,
|
||||||
useBaseUrl,
|
useBaseUrl,
|
||||||
useClientContext
|
|
||||||
} from "./ClientProvider";
|
} from "./ClientProvider";
|
||||||
|
|
||||||
export * from "./api/use-api";
|
export * from "./api/use-api";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AuthState } from "Api";
|
import type { AuthState } from "Api";
|
||||||
import type { AuthResponse } from "bknd";
|
import type { AuthResponse } from "bknd";
|
||||||
import { useApi, useInvalidate, useClientContext } from "bknd/client";
|
import { useApi, useInvalidate } from "ui/client";
|
||||||
|
import { useClientContext } from "ui/client/ClientProvider";
|
||||||
|
|
||||||
type LoginData = {
|
type LoginData = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -18,7 +19,6 @@ type UseAuth = {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
verify: () => Promise<void>;
|
verify: () => Promise<void>;
|
||||||
setToken: (token: string) => void;
|
setToken: (token: string) => void;
|
||||||
local: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||||
@@ -61,6 +61,5 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
|||||||
logout,
|
logout,
|
||||||
setToken,
|
setToken,
|
||||||
verify,
|
verify,
|
||||||
local: !!api.options.storage,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, this.options.admin_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() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Children, forwardRef } from "react";
|
import { Children } from "react";
|
||||||
|
import { forwardRef } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Tooltip } from "@mantine/core";
|
import { Tooltip } from "@mantine/core";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { getBrowser } from "bknd/utils";
|
import { getBrowser } from "core/utils";
|
||||||
import type { Field } from "data/fields";
|
import type { Field } from "data/fields";
|
||||||
import { Switch as RadixSwitch } from "radix-ui";
|
import { Switch as RadixSwitch } from "radix-ui";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -16,18 +16,15 @@ import {
|
|||||||
setPath,
|
setPath,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
|
export type NativeFormProps = {
|
||||||
hiddenSubmit?: boolean;
|
hiddenSubmit?: boolean;
|
||||||
validateOn?: "change" | "submit";
|
validateOn?: "change" | "submit";
|
||||||
errorFieldSelector?: (selector: string) => any | null;
|
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
|
||||||
reportValidity?: boolean;
|
reportValidity?: boolean;
|
||||||
onSubmit?: (
|
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
|
||||||
data: any,
|
|
||||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
|
||||||
) => Promise<void> | void;
|
|
||||||
onSubmitInvalid?: (
|
onSubmitInvalid?: (
|
||||||
errors: InputError[],
|
errors: InputError[],
|
||||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
ctx: { event: FormEvent<HTMLFormElement> },
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onError?: (errors: InputError[]) => void;
|
onError?: (errors: InputError[]) => void;
|
||||||
disableSubmitOnError?: boolean;
|
disableSubmitOnError?: boolean;
|
||||||
@@ -36,7 +33,7 @@ export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange"
|
|||||||
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
|
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
clean?: CleanOptions | true;
|
clean?: CleanOptions | true;
|
||||||
};
|
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
|
||||||
|
|
||||||
export type InputError = {
|
export type InputError = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -191,12 +188,12 @@ export function NativeForm({
|
|||||||
|
|
||||||
const errors = validate({ report: true });
|
const errors = validate({ report: true });
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
onSubmitInvalid?.(errors, { event: e, form });
|
onSubmitInvalid?.(errors, { event: e });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
await onSubmit(getFormValues(), { event: e, form });
|
await onSubmit(getFormValues(), { event: e });
|
||||||
} else {
|
} else {
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
import { useInsertionEffect, useRef } from "react";
|
||||||
|
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
|
||||||
import { useEvent } from "../../hooks/use-event";
|
import { useEvent } from "../../hooks/use-event";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { NativeForm } from "ui/components/form/native-form/NativeForm";
|
import { NativeForm } from "ui/components/form/native-form/NativeForm";
|
||||||
import { transformObject } from "bknd/utils";
|
import { transform } from "lodash-es";
|
||||||
import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react";
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
|
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
|
||||||
import { SocialLink } from "./SocialLink";
|
import { SocialLink } from "./SocialLink";
|
||||||
import { useAuth } from "bknd/client";
|
|
||||||
import { Alert } from "ui/components/display/Alert";
|
|
||||||
import { useLocation } from "wouter";
|
|
||||||
|
|
||||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "action"> & {
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||||
className?: string;
|
className?: string;
|
||||||
formData?: any;
|
formData?: any;
|
||||||
action: "login" | "register";
|
action: "login" | "register";
|
||||||
@@ -26,50 +23,25 @@ export function AuthForm({
|
|||||||
action,
|
action,
|
||||||
auth,
|
auth,
|
||||||
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||||
onSubmit: _onSubmit,
|
|
||||||
...props
|
...props
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
const $auth = useAuth();
|
|
||||||
const basepath = auth?.basepath ?? "/api/auth";
|
const basepath = auth?.basepath ?? "/api/auth";
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const [, navigate] = useLocation();
|
|
||||||
const password = {
|
const password = {
|
||||||
action: `${basepath}/password/${action}`,
|
action: `${basepath}/password/${action}`,
|
||||||
strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
|
strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
|
||||||
};
|
};
|
||||||
|
|
||||||
const oauth = transformObject(auth?.strategies ?? {}, (value) => {
|
const oauth = transform(
|
||||||
return value.type !== "password" ? value.config : undefined;
|
auth?.strategies ?? {},
|
||||||
}) as Record<string, AppAuthOAuthStrategy>;
|
(result, value, key) => {
|
||||||
|
if (value.type !== "password") {
|
||||||
|
result[key] = value.config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
) as Record<string, AppAuthOAuthStrategy>;
|
||||||
const has_oauth = Object.keys(oauth).length > 0;
|
const has_oauth = Object.keys(oauth).length > 0;
|
||||||
|
|
||||||
async function onSubmit(
|
|
||||||
data: any,
|
|
||||||
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
|
|
||||||
) {
|
|
||||||
if ($auth?.local) {
|
|
||||||
ctx.event.preventDefault();
|
|
||||||
|
|
||||||
const res = await $auth.login(data);
|
|
||||||
if ("token" in res) {
|
|
||||||
navigate("/");
|
|
||||||
} else {
|
|
||||||
setError((res as any).error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _onSubmit?.(ctx.event);
|
|
||||||
// submit form
|
|
||||||
ctx.form.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ($auth.user) {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
}, [$auth.user]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{has_oauth && (
|
{has_oauth && (
|
||||||
@@ -91,19 +63,17 @@ export function AuthForm({
|
|||||||
<NativeForm
|
<NativeForm
|
||||||
method={method}
|
method={method}
|
||||||
action={password.action}
|
action={password.action}
|
||||||
onSubmit={onSubmit}
|
|
||||||
{...(props as any)}
|
{...(props as any)}
|
||||||
validateOn="change"
|
validateOn="change"
|
||||||
className={clsx("flex flex-col gap-3 w-full", className)}
|
className={clsx("flex flex-col gap-3 w-full", className)}
|
||||||
>
|
>
|
||||||
{error && <Alert.Exception message={error} className="justify-center" />}
|
|
||||||
<Group>
|
<Group>
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Label htmlFor="email">Email address</Label>
|
||||||
<Input type="email" name="email" required />
|
<Input type="email" name="email" required />
|
||||||
</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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ucFirstAllSnakeToPascalWithSpaces } from "bknd/utils";
|
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import type { IconType } from "ui/components/buttons/IconButton";
|
import type { IconType } from "ui/components/buttons/IconButton";
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { AppAuthSchema } from "auth/auth-schema";
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useApi } from "bknd/client";
|
import { useApi } from "ui/client";
|
||||||
|
|
||||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||||
export const useAuthStrategies = (options?: {
|
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||||
baseUrl?: string;
|
|
||||||
}): Partial<AuthStrategyData> & {
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
} => {
|
} => {
|
||||||
const [data, setData] = useState<AuthStrategyData>();
|
const [data, setData] = useState<AuthStrategyData>();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
|
|||||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||||
import { checkMaxReached } from "./helper";
|
import { checkMaxReached } from "./helper";
|
||||||
import { DropzoneInner } from "./DropzoneInner";
|
import { DropzoneInner } from "./DropzoneInner";
|
||||||
import { createDropzoneStore } from "./dropzone-state";
|
import { createDropzoneStore } from "ui/elements/media/dropzone-state";
|
||||||
import { useStore } from "zustand";
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
export type FileState = {
|
export type FileState = {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { Api } from "bknd/client";
|
||||||
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
|
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
|
||||||
import type { MediaFieldSchema } from "media/AppMedia";
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
import type { TAppMediaConfig } from "media/media-schema";
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
import { useId, useEffect, useRef, useState } from "react";
|
import { useId, useEffect, useRef, useState } from "react";
|
||||||
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||||
import { mediaItemsToFileStates } from "./helper";
|
import { mediaItemsToFileStates } from "./helper";
|
||||||
@@ -131,6 +132,7 @@ export function DropzoneContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
key={key}
|
key={key}
|
||||||
getUploadInfo={getUploadInfo}
|
getUploadInfo={getUploadInfo}
|
||||||
@@ -149,6 +151,7 @@ export function DropzoneContainer({
|
|||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { formatNumber } from "bknd/utils";
|
import { formatNumber } from "core/utils";
|
||||||
import type { DropzoneRenderProps, FileState } from "./Dropzone";
|
import type { DropzoneRenderProps, FileState } from "ui/elements";
|
||||||
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||||
|
|
||||||
function handleUploadError(e: unknown) {
|
function handleUploadError(e: unknown) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user