mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e81636ea95 | |||
| 37c36e5d07 |
@@ -9,21 +9,6 @@ jobs:
|
||||
test:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -35,11 +20,11 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.3.3"
|
||||
bun-version: "1.2.22"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./app
|
||||
run: bun install #--linker=hoisted
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: ./app
|
||||
|
||||
+1
-2
@@ -27,8 +27,7 @@ packages/media/.env
|
||||
.npmrc
|
||||
/.verdaccio
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
.vscode
|
||||
.git_old
|
||||
docker/tmp
|
||||
.debug
|
||||
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"biome.enabled": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
//"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**/dist/**",
|
||||
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
||||
],
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
|
||||
}
|
||||
}
|
||||
+77
-169
@@ -1,202 +1,110 @@
|
||||
# Functional Source License, Version 1.1, MIT Future License
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
## Abbreviation
|
||||
|
||||
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,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
Copyright 2025 Dennis Senn
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
## Terms and Conditions
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
### Licensor ("We")
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
### The Software
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
### License Grant
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
### Permitted Purpose
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
1. substitutes for the Software;
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(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.
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
Permitted Purposes specifically include using the Software:
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
1. for your internal use and access;
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
2. for non-commercial education;
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
3. for non-commercial research; and
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
### Patents
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
### Redistribution
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
|
||||
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
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
|
||||
Copyright 2026 Dennis Senn
|
||||
### Trademarks
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
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
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the MIT license that is effective on the second anniversary of the date we make
|
||||
the Software available. On or after that date, you may use the Software under
|
||||
the MIT license, in which case the following will apply:
|
||||
|
||||
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
|
||||
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||
* **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.
|
||||
|
||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||
|
||||
@@ -20,7 +20,6 @@ VITE_SHOW_ROUTES=
|
||||
|
||||
# ===== Test Credentials =====
|
||||
RESEND_API_KEY=
|
||||
PLUNK_API_KEY=
|
||||
R2_TOKEN=
|
||||
|
||||
R2_ACCESS_KEY=
|
||||
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"typescript.preferences.includePackageJsonAutoImports": "off",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
"**/node_modules/**": true
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/dist/**": true
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ describe("App tests", async () => {
|
||||
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
|
||||
});
|
||||
|
||||
test("drivers", async () => {
|
||||
test.only("drivers", async () => {
|
||||
const called: string[] = [];
|
||||
const app = new App(dummyConnection, undefined, {
|
||||
drivers: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="@types/bun" />
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
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 { assetsPath, assetsTmpPath } from "../helper";
|
||||
|
||||
@@ -15,7 +15,7 @@ const mockedBackend = new Hono()
|
||||
.get("/file/:name", async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const file = Bun.file(`${assetsPath}/${name}`);
|
||||
return new Response(new File([await file.bytes()], name, { type: file.type }), {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"Content-Length": file.size.toString(),
|
||||
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
|
||||
const res = await mockedBackend.request("/api/media/file/" + name);
|
||||
await Bun.write(path, res);
|
||||
|
||||
const file = Bun.file(path);
|
||||
const file = await Bun.file(path);
|
||||
expect(file.size).toBeGreaterThan(0);
|
||||
expect(file.type).toBe("image/png");
|
||||
await file.delete();
|
||||
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
|
||||
expect(isReadableStream(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(blob.size).toBeGreaterThan(0);
|
||||
expect(blob.type).toBe("image/png");
|
||||
@@ -113,7 +113,7 @@ describe("MediaApi", () => {
|
||||
const res = await api.getFileStream(name);
|
||||
expect(isReadableStream(res)).toBe(true);
|
||||
|
||||
const blob = (await new Response(res).blob()) as File;
|
||||
const blob = await new Response(res).blob();
|
||||
expect(isFile(blob)).toBe(true);
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
expect(blob.type).toBe("image/png");
|
||||
@@ -154,38 +154,15 @@ describe("MediaApi", () => {
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
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 { getDummyConnection } from "../../__test__/helper";
|
||||
import { ModuleManager } from "modules/ModuleManager";
|
||||
import { em, entity, text } from "data/prototype";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
async function createApp(config: CreateAppConfig = {}) {
|
||||
const app = internalCreateApp({
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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 { McpServer } from "bknd/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [x] system_config
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { code, hybrid } from "modes";
|
||||
|
||||
describe("modes", () => {
|
||||
describe("code", () => {
|
||||
test("verify base configuration", async () => {
|
||||
const c = code({}) as any;
|
||||
const config = await c.app?.({} as any);
|
||||
expect(Object.keys(config)).toEqual(["options"]);
|
||||
expect(config.options.mode).toEqual("code");
|
||||
expect(config.options.plugins).toEqual([]);
|
||||
expect(config.options.manager.skipValidation).toEqual(false);
|
||||
expect(config.options.manager.onModulesBuilt).toBeDefined();
|
||||
});
|
||||
|
||||
test("keeps overrides", async () => {
|
||||
const c = code({
|
||||
connection: {
|
||||
url: ":memory:",
|
||||
},
|
||||
}) as any;
|
||||
const config = await c.app?.({} as any);
|
||||
expect(config.connection.url).toEqual(":memory:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hybrid", () => {
|
||||
test("fails if no reader is provided", () => {
|
||||
// @ts-ignore
|
||||
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
|
||||
});
|
||||
test("verify base configuration", async () => {
|
||||
const c = hybrid({ reader: async () => ({}) }) as any;
|
||||
const config = await c.app?.({} as any);
|
||||
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
|
||||
expect(config.options.mode).toEqual("db");
|
||||
expect(config.options.plugins).toEqual([]);
|
||||
expect(config.options.manager.skipValidation).toEqual(false);
|
||||
expect(config.options.manager.onModulesBuilt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe("repros", async () => {
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
||||
});
|
||||
|
||||
test("verify inversedBy", async () => {
|
||||
test.only("verify inversedBy", async () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
products: proto.entity("products", {
|
||||
|
||||
@@ -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 { Permission } from "auth/authorize/Permission";
|
||||
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||
import { objectTransform, s } from "bknd/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
function createGuard(
|
||||
permissionNames: string[],
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
||||
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]),
|
||||
).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 () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
|
||||
dataType: "INTEGER",
|
||||
isNullable: false,
|
||||
isAutoIncrementing: true,
|
||||
hasDefaultValue: true,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
|
||||
dataType: "INTEGER",
|
||||
isNullable: false,
|
||||
isAutoIncrementing: true,
|
||||
hasDefaultValue: true,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp, type AuthResponse } from "../../src";
|
||||
import { auth } from "../../src/modules/middlewares";
|
||||
import {
|
||||
mergeObject,
|
||||
randomString,
|
||||
secureRandomString,
|
||||
withDisabledConsole,
|
||||
} from "../../src/core/utils";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -68,12 +62,12 @@ const configs = {
|
||||
},
|
||||
};
|
||||
|
||||
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
||||
function createAuthApp() {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
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 };
|
||||
},
|
||||
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">> => {
|
||||
const res = (await app.server.request("/api/auth/me", {
|
||||
method: "GET",
|
||||
@@ -261,61 +245,4 @@ describe("integration auth", () => {
|
||||
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 { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import * as proto from "data/prototype";
|
||||
|
||||
beforeAll(() => {
|
||||
disableConsoleLog();
|
||||
//disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -129,87 +128,4 @@ describe("MediaController", () => {
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
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);
|
||||
|
||||
describe("AppAuth", () => {
|
||||
test.skip("...", () => {
|
||||
const auth = new AppAuth({});
|
||||
console.log(auth.toJSON());
|
||||
console.log(auth.config);
|
||||
});
|
||||
|
||||
moduleTestSuite(AppAuth);
|
||||
|
||||
let ctx: ModuleBuildContext;
|
||||
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
|
||||
await auth.build();
|
||||
|
||||
const oldConfig = auth.toJSON(true);
|
||||
//console.log(oldConfig);
|
||||
await auth.schema().patch("enabled", true);
|
||||
await auth.build();
|
||||
const newConfig = auth.toJSON(true);
|
||||
//console.log(newConfig);
|
||||
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
||||
});
|
||||
|
||||
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
|
||||
const app = new AuthController(auth).getController();
|
||||
|
||||
{
|
||||
disableConsoleLog();
|
||||
const res = await app.request("/password/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
|
||||
password: "12345678",
|
||||
}),
|
||||
});
|
||||
enableConsoleLog();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { data: users } = await ctx.em.repository("users").findMany();
|
||||
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
|
||||
const app = new AuthController(auth).getController();
|
||||
|
||||
{
|
||||
disableConsoleLog();
|
||||
const res = await app.request("/password/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
|
||||
password: "12345678",
|
||||
}),
|
||||
});
|
||||
enableConsoleLog();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
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 { em, entity, text } from "data/prototype";
|
||||
import { registries } from "modules/registries";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { AppMedia } from "../../src/media/AppMedia";
|
||||
import { moduleTestSuite } from "./module-test-suite";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("AppMedia", () => {
|
||||
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 { getDummyConnection } from "../helper";
|
||||
import { TABLE_NAME } from "modules/db/migrations";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("DbModuleManager", () => {
|
||||
it("should extract secrets", async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import { entity, text } from "data/prototype";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("ModuleManager", async () => {
|
||||
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
//const { version, ...json } = mm.toJSON() as any;
|
||||
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const db = dummyConnection.kysely;
|
||||
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
|
||||
|
||||
await mm2.build();
|
||||
|
||||
/* console.log({
|
||||
json,
|
||||
configs: mm2.configs(),
|
||||
}); */
|
||||
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
||||
expect(mm2.configs().data.entities?.test).toBeDefined();
|
||||
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
||||
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
|
||||
const c = getDummyConnection();
|
||||
const mm = new ModuleManager(c.dummyConnection);
|
||||
await mm.build();
|
||||
console.log("==".repeat(30));
|
||||
console.log("");
|
||||
const json = mm.configs();
|
||||
|
||||
const c2 = getDummyConnection();
|
||||
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
|
||||
}
|
||||
|
||||
override async build() {
|
||||
//console.log("building FailingModule", this.config);
|
||||
if (this.config.value && this.config.value < 0) {
|
||||
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 () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const mm = new TestModuleManager(dummyConnection);
|
||||
|
||||
@@ -7,7 +7,9 @@ import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import v9 from "./samples/v9.json";
|
||||
import v10 from "./samples/v10.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { CURRENT_VERSION } from "modules/db/migrations";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -61,7 +63,7 @@ async function getRawConfig(
|
||||
return await db
|
||||
.selectFrom("__bknd")
|
||||
.selectAll()
|
||||
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
||||
.where("version", "=", opts?.version ?? CURRENT_VERSION)
|
||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||
.execute();
|
||||
}
|
||||
@@ -115,7 +117,6 @@ describe("Migrations", () => {
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
const [config, secrets] = (await getRawConfig(app, {
|
||||
version: 10,
|
||||
types: ["config", "secrets"],
|
||||
})) as any;
|
||||
|
||||
@@ -129,4 +130,15 @@ describe("Migrations", () => {
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
});
|
||||
|
||||
test("migration from 10 to 11", async () => {
|
||||
expect(v10.version).toBe(10);
|
||||
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
|
||||
|
||||
const app = await createVersionedApp(v10);
|
||||
|
||||
expect(app.version()).toBeGreaterThan(10);
|
||||
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
|
||||
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"version": 10,
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"path": "/api/system/mcp",
|
||||
"logLevel": "warning"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {
|
||||
"test": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["read", "update"]
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"default_value": "INACTIVE",
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
|
||||
},
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": {
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"type": "jsonschema",
|
||||
"config": {
|
||||
"default_from_schema": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"one": {
|
||||
"type": "number",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": true,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"type": "boolean",
|
||||
"config": {
|
||||
"default_value": false,
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"type": "number",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"etag": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"modified_at": {
|
||||
"type": "date",
|
||||
"config": {
|
||||
"type": "datetime",
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"reference": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"entity_id": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "json",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort_field": "id",
|
||||
"sort_dir": "asc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {},
|
||||
"indices": {
|
||||
"idx_unique_media_path": {
|
||||
"entity": "media",
|
||||
"fields": ["path"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_media_reference": {
|
||||
"entity": "media",
|
||||
"fields": ["reference"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_media_entity_id": {
|
||||
"entity": "media",
|
||||
"fields": ["entity_id"],
|
||||
"unique": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guard": {
|
||||
"enabled": false
|
||||
},
|
||||
"roles": {}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
import c from "picocolors";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: ["./src/cli/index.ts"],
|
||||
outdir: "./dist/cli",
|
||||
platform: "node",
|
||||
minify: true,
|
||||
format: "esm",
|
||||
metafile: true,
|
||||
bundle: true,
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/cli/index.ts"],
|
||||
target: "node",
|
||||
|
||||
+5
-67
@@ -2,8 +2,6 @@ import { $ } from "bun";
|
||||
import * as tsup from "tsup";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
import c from "picocolors";
|
||||
import { watch as fsWatch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const watch = args.includes("--watch");
|
||||
@@ -85,8 +83,7 @@ async function buildApi() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
// don't use tsup's broken watch, we'll handle it ourselves
|
||||
watch: false,
|
||||
watch,
|
||||
define,
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
@@ -99,7 +96,6 @@ async function buildApi() {
|
||||
metafile: true,
|
||||
target: "esnext",
|
||||
platform: "browser",
|
||||
removeNodeProtocol: false,
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
loader: {
|
||||
@@ -124,7 +120,7 @@ async function buildUi() {
|
||||
const base = {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch: false,
|
||||
watch,
|
||||
define,
|
||||
external: [
|
||||
...external,
|
||||
@@ -183,15 +179,12 @@ async function buildUiElements() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch: false,
|
||||
watch,
|
||||
define,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
"ui/client",
|
||||
"bknd",
|
||||
/^bknd\/.*/,
|
||||
"wouter",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
@@ -228,14 +221,13 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
||||
return {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch: false,
|
||||
watch,
|
||||
entry: [`src/adapter/${adapter}/index.ts`],
|
||||
format: ["esm"],
|
||||
platform: "neutral",
|
||||
outDir: `dist/adapter/${adapter}`,
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
removeNodeProtocol: false,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||
@@ -271,11 +263,6 @@ async function buildAdapters() {
|
||||
|
||||
// specific adatpers
|
||||
tsup.build(baseConfig("react-router")),
|
||||
tsup.build(
|
||||
baseConfig("browser", {
|
||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
||||
}),
|
||||
),
|
||||
tsup.build(
|
||||
baseConfig("bun", {
|
||||
external: [/^bun\:.*/],
|
||||
@@ -308,11 +295,6 @@ async function buildAdapters() {
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("sveltekit"),
|
||||
platform: "node",
|
||||
}),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node",
|
||||
@@ -343,48 +325,4 @@ async function buildAdapters() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function buildAll() {
|
||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||
}
|
||||
|
||||
// initial build
|
||||
await buildAll();
|
||||
|
||||
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||
if (watch) {
|
||||
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isBuilding = false;
|
||||
|
||||
const rebuild = async () => {
|
||||
if (isBuilding) return;
|
||||
isBuilding = true;
|
||||
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||
try {
|
||||
await buildAll();
|
||||
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||
} catch (e) {
|
||||
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||
}
|
||||
isBuilding = false;
|
||||
};
|
||||
|
||||
const debouncedRebuild = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(rebuild, 100);
|
||||
};
|
||||
|
||||
// watch src directory recursively
|
||||
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
||||
if (!filename) return;
|
||||
// ignore non-source files
|
||||
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
||||
return;
|
||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||
debouncedRebuild();
|
||||
});
|
||||
|
||||
// keep process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[install]
|
||||
#registry = "http://localhost:4873"
|
||||
|
||||
[test]
|
||||
coverageSkipTestFiles = true
|
||||
console.depth = 10
|
||||
+50
-73
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"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.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/bknd-io/bknd/issues"
|
||||
},
|
||||
"packageManager": "bun@1.3.3",
|
||||
"packageManager": "bun@1.2.22",
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
},
|
||||
@@ -49,103 +49,93 @@
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hono/swagger-ui": "^0.5.2",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@uiw/react-codemirror": "^4.25.2",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"fast-xml-parser": "^5.3.1",
|
||||
"hono": "4.10.4",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-xml-parser": "^5.0.8",
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "^0.10.1",
|
||||
"kysely": "0.28.8",
|
||||
"jsonv-ts": "0.9.1",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"radix-ui": "^1.1.3",
|
||||
"swr": "^2.3.6",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zustand": "^4"
|
||||
"picocolors": "^1.1.1",
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.922.0",
|
||||
"@bluwy/giget-core": "^0.1.6",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||
"@cloudflare/workers-types": "^4.20251014.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.9.3",
|
||||
"@cloudflare/workers-types": "^4.20250606.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/vite-dev-server": "^0.23.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@hono/vite-dev-server": "^0.21.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
"@mantine/notifications": "^7.17.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rjsf/core": "5.22.2",
|
||||
"@rjsf/utils": "5.22.0",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@tabler/icons-react": "3.35.0",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/react-store": "^0.8.0",
|
||||
"@tabler/icons-react": "3.18.0",
|
||||
"@tailwindcss/postcss": "^4.0.12",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "3.0.9",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"kysely-generic-sqlite": "^1.2.1",
|
||||
"kysely-postgres-js": "^2.0.0",
|
||||
"libsql": "^0.5.22",
|
||||
"libsql-stateless-easy": "^1.8.0",
|
||||
"miniflare": "^4.20251011.2",
|
||||
"open": "^10.2.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"postgres": "^3.4.7",
|
||||
"posthog-js-lite": "^3.6.0",
|
||||
"posthog-js-lite": "^3.4.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"sql-formatter": "^15.6.10",
|
||||
"sqlocal": "^0.16.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "5.2.1",
|
||||
"react-json-view-lite": "^2.4.1",
|
||||
"sql-formatter": "^15.4.11",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.6",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.1.12",
|
||||
"tsc-alias": "^1.8.11",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-circular-dependency": "^0.5.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "3.0.9",
|
||||
"wouter": "^3.7.1",
|
||||
"wrangler": "^4.45.4"
|
||||
"vitest": "^3.0.9",
|
||||
"wouter": "^3.6.0",
|
||||
"wrangler": "^4.37.1",
|
||||
"miniflare": "^4.20250913.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.19.6"
|
||||
"@hono/node-server": "^1.14.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
@@ -253,21 +243,11 @@
|
||||
"import": "./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": {
|
||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||
"import": "./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"
|
||||
},
|
||||
"./dist/main.css": "./dist/ui/main.css",
|
||||
"./dist/styles.css": "./dist/ui/styles.css",
|
||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
||||
@@ -285,7 +265,6 @@
|
||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||
}
|
||||
},
|
||||
@@ -315,8 +294,6 @@
|
||||
"remix",
|
||||
"react-router",
|
||||
"astro",
|
||||
"sveltekit",
|
||||
"svelte",
|
||||
"bun",
|
||||
"node"
|
||||
]
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export class Api {
|
||||
private token?: string;
|
||||
private user?: TApiUser;
|
||||
private verified = false;
|
||||
public token_transport: "header" | "cookie" | "none" = "header";
|
||||
private token_transport: "header" | "cookie" | "none" = "header";
|
||||
|
||||
public system!: SystemApi;
|
||||
public data!: DataApi;
|
||||
|
||||
+2
-3
@@ -5,6 +5,7 @@ import type { em as prototypeEm } from "data/prototype";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
type ModuleConfigs,
|
||||
type Modules,
|
||||
ModuleManager,
|
||||
@@ -380,10 +381,8 @@ export class App<
|
||||
if (results.length > 0) {
|
||||
for (const { name, result } of results) {
|
||||
if (result) {
|
||||
$console.log(`[Plugin:${name}] schema`);
|
||||
ctx.helper.ensureSchema(result);
|
||||
if (ctx.flags.sync_required) {
|
||||
$console.log(`[Plugin:${name}] schema, sync required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +1,14 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import path from "node:path";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from ".";
|
||||
import { config, type App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
import { serveStatic } from "hono/bun";
|
||||
|
||||
type BunEnv = Bun.Env;
|
||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> &
|
||||
Omit<Bun.Serve.Options<undefined, string>, "fetch">;
|
||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||
|
||||
export async function createApp<Env = BunEnv>(
|
||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||
@@ -43,7 +45,6 @@ export function createHandler<Env = BunEnv>(
|
||||
|
||||
export function serve<Env = BunEnv>(
|
||||
{
|
||||
app,
|
||||
distPath,
|
||||
connection,
|
||||
config: _config,
|
||||
@@ -59,11 +60,10 @@ export function serve<Env = BunEnv>(
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
Bun.serve({
|
||||
...(serveOptions as any),
|
||||
...serveOptions,
|
||||
port,
|
||||
fetch: createHandler(
|
||||
{
|
||||
app,
|
||||
connection,
|
||||
config: _config,
|
||||
options,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { App, MaybePromise } from "bknd";
|
||||
import type { MaybePromise } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||
@@ -55,12 +55,8 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
// compatiblity
|
||||
export const getFresh = createApp;
|
||||
|
||||
let app: App | undefined;
|
||||
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
serveOptions?: (args: Env) => {
|
||||
warm?: boolean;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
@@ -96,11 +92,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
}
|
||||
}
|
||||
|
||||
const { warm } = serveOptions?.(env) ?? {};
|
||||
if (!app || warm !== true) {
|
||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||
app = await createApp(config, context);
|
||||
}
|
||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||
const app = await createApp(config, context);
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
|
||||
@@ -65,31 +65,37 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
beforeBuild: async (app, registries) => {
|
||||
if (!use_proxy) return;
|
||||
const env = await getEnv();
|
||||
registerMedia(env, registries as any);
|
||||
await config?.beforeBuild?.(app, registries);
|
||||
},
|
||||
bindings: async (env) => {
|
||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||
},
|
||||
// @ts-ignore
|
||||
app: async (_env) => {
|
||||
const env = await getEnv(_env);
|
||||
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
||||
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
|
||||
const connection =
|
||||
use_proxy && binding
|
||||
? d1Sqlite({
|
||||
binding: binding.value as any,
|
||||
})
|
||||
: appConfig.connection;
|
||||
|
||||
return {
|
||||
...appConfig,
|
||||
beforeBuild: async (app, registries) => {
|
||||
if (!use_proxy) return;
|
||||
const env = await getEnv();
|
||||
registerMedia(env, registries as any);
|
||||
await config?.beforeBuild?.(app, registries);
|
||||
},
|
||||
bindings: async (env) => {
|
||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||
},
|
||||
connection,
|
||||
};
|
||||
if (config?.app === undefined && use_proxy && binding) {
|
||||
return {
|
||||
connection: d1Sqlite({
|
||||
binding: binding.value,
|
||||
}),
|
||||
};
|
||||
} else if (typeof config?.app === "function") {
|
||||
const appConfig = await config?.app(env);
|
||||
if (binding) {
|
||||
appConfig.connection = d1Sqlite({
|
||||
binding: binding.value,
|
||||
}) as any;
|
||||
}
|
||||
return appConfig;
|
||||
}
|
||||
return config?.app || {};
|
||||
},
|
||||
} satisfies CloudflareBkndConfig<Env>;
|
||||
}
|
||||
|
||||
@@ -14,15 +14,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||
CreateAppConfig &
|
||||
Omit<Additional, "app"> & {
|
||||
app?:
|
||||
| Omit<BkndConfig<Args, Additional>, "app">
|
||||
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
}
|
||||
CreateAppConfig & {
|
||||
app?:
|
||||
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
} & Additional
|
||||
>;
|
||||
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
||||
import { isNode } from "bknd/utils";
|
||||
// @ts-expect-error next is not installed
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
type NextjsEnv = NextApiRequest["env"];
|
||||
@@ -19,9 +18,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
if (!cleanRequest) return req;
|
||||
|
||||
const url = new URL(req.url);
|
||||
cleanRequest?.searchParams?.forEach((k) => {
|
||||
url.searchParams.delete(k);
|
||||
});
|
||||
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
|
||||
|
||||
if (isNode()) {
|
||||
return new Request(url.toString(), {
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function createApp<Env = NodeEnv>(
|
||||
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
||||
);
|
||||
if (relativeDistPath) {
|
||||
$console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||
}
|
||||
|
||||
registerLocalMediaAdapter();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe } from "vitest";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
import * as node from "./node.adapter";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
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
-29
@@ -46,22 +46,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -98,7 +82,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||
jwt: this.config.jwt,
|
||||
cookie: this.config.cookie,
|
||||
default_role_register: this.config.default_role_register,
|
||||
});
|
||||
|
||||
this.registerEntities();
|
||||
@@ -188,20 +171,10 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async createUser({
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
...additional
|
||||
}: CreateUserPayload): Promise<DB["users"]> {
|
||||
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||
if (!this.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 pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||
@@ -210,7 +183,6 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const { data: created } = await mutator.insertOne({
|
||||
...(additional as any),
|
||||
role: role || this.config.default_role_register || undefined,
|
||||
email,
|
||||
strategy,
|
||||
strategy_value,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
InvalidSchemaError,
|
||||
transformObject,
|
||||
mcpTool,
|
||||
$console,
|
||||
} from "bknd/utils";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
|
||||
@@ -211,7 +210,7 @@ export class AuthController extends Controller {
|
||||
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||
|
||||
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||
let user: DB["users"] | undefined;
|
||||
let user: DB["users"] | undefined = undefined;
|
||||
if (params.id) {
|
||||
const { data } = await this.userRepo.findId(params.id);
|
||||
user = data;
|
||||
@@ -226,33 +225,26 @@ export class AuthController extends Controller {
|
||||
};
|
||||
|
||||
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(
|
||||
"auth_user_create",
|
||||
{
|
||||
description: "Create a new user",
|
||||
inputSchema: s.object({
|
||||
...schema.properties,
|
||||
role: s
|
||||
.string({
|
||||
enum: roles.length > 0 ? roles : undefined,
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||
mcp.tool(
|
||||
"auth_user_create",
|
||||
{
|
||||
description: "Create a new user",
|
||||
inputSchema: s.object({
|
||||
email: s.string({ format: "email" }),
|
||||
password: s.string({ minLength: 8 }),
|
||||
role: s
|
||||
.string({
|
||||
enum: roles.length > 0 ? roles : undefined,
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
async (params, c) => {
|
||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||
|
||||
return c.json(await this.auth.createUser(params));
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
$console.warn("error creating auth_user_create tool", e);
|
||||
}
|
||||
return c.json(await this.auth.createUser(params));
|
||||
},
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
"auth_user_token",
|
||||
|
||||
@@ -51,7 +51,6 @@ export const authConfigSchema = $object(
|
||||
basepath: s.string({ default: "/api/auth" }),
|
||||
entity_name: s.string({ default: "users" }),
|
||||
allow_register: s.boolean({ default: true }).optional(),
|
||||
default_role_register: s.string().optional(),
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
strategies: $record(
|
||||
|
||||
@@ -74,7 +74,6 @@ export const jwtConfig = s.strictObject(
|
||||
export const authenticatorConfig = s.object({
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
default_role_register: s.string().optional(),
|
||||
});
|
||||
|
||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||
@@ -165,13 +164,9 @@ export class Authenticator<
|
||||
if (!("strategy_value" in profile)) {
|
||||
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(), {
|
||||
...profile,
|
||||
role: this.config.default_role_register,
|
||||
strategy_value: profile.strategy_value,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const schema = s
|
||||
.object({
|
||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||
minLength: s.number({ default: 8, minimum: 1 }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -38,7 +37,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
format: "email",
|
||||
}),
|
||||
password: s.string({
|
||||
minLength: this.config.minLength,
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -66,21 +65,12 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
return await bcryptCompare(compare, actual);
|
||||
}
|
||||
|
||||
return actual === compare;
|
||||
return false;
|
||||
}
|
||||
|
||||
verify(password: string) {
|
||||
return async (user: User) => {
|
||||
if (!user || !user.strategy_value) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
|
||||
$console.debug("PasswordStrategy: Invalid password", password);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
const compare = await this.compare(user.strategy_value, password);
|
||||
const compare = await this.compare(user?.strategy_value!, password);
|
||||
if (compare !== true) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
@@ -67,10 +67,7 @@ export async function startServer(
|
||||
$console.info("Server listening on", url);
|
||||
|
||||
if (options.open) {
|
||||
const p = await open(url, { wait: false });
|
||||
p.on("error", () => {
|
||||
$console.warn("Couldn't open url in browser");
|
||||
});
|
||||
await open(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { plunkEmail } from "./plunk";
|
||||
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
describe.skipIf(ALL_TESTS)("plunk", () => {
|
||||
it("should throw on failed", async () => {
|
||||
const driver = plunkEmail({ apiKey: "invalid" });
|
||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should send an email", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined, // Default to what Plunk sets
|
||||
});
|
||||
const response = await driver.send(
|
||||
"help@bknd.io",
|
||||
"Test Email from Plunk",
|
||||
"This is a test email",
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.emails).toBeDefined();
|
||||
expect(response.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("should send HTML email", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined,
|
||||
});
|
||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
||||
const response = await driver.send(
|
||||
"help@bknd.io",
|
||||
"HTML Test",
|
||||
htmlBody,
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should send with text and html", async () => {
|
||||
const driver = plunkEmail({
|
||||
apiKey: process.env.PLUNK_API_KEY!,
|
||||
from: undefined,
|
||||
});
|
||||
const response = await driver.send("test@example.com", "Test Email", {
|
||||
text: "help@bknd.io",
|
||||
html: "<p>This is HTML</p>",
|
||||
});
|
||||
expect(response).toBeDefined();
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { IEmailDriver } from "./index";
|
||||
|
||||
export type PlunkEmailOptions = {
|
||||
apiKey: string;
|
||||
host?: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
export type PlunkEmailSendOptions = {
|
||||
subscribed?: boolean;
|
||||
name?: string;
|
||||
from?: string;
|
||||
reply?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PlunkEmailResponse = {
|
||||
success: boolean;
|
||||
emails: Array<{
|
||||
contact: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
email: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export const plunkEmail = (
|
||||
config: PlunkEmailOptions,
|
||||
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
||||
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
||||
const from = config.from;
|
||||
|
||||
return {
|
||||
send: async (
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string | { text: string; html: string },
|
||||
options?: PlunkEmailSendOptions,
|
||||
) => {
|
||||
const payload: any = {
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
};
|
||||
|
||||
if (typeof body === "string") {
|
||||
payload.body = body;
|
||||
} else {
|
||||
payload.body = body.html;
|
||||
}
|
||||
|
||||
const res = await fetch(host, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ ...payload, ...options }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Plunk API error: ${await res.text()}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as PlunkEmailResponse;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { resendEmail } from "./resend";
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
describe.skipIf(ALL_TESTS)("resend", () => {
|
||||
it("should throw on failed", async () => {
|
||||
it.only("should throw on failed", async () => {
|
||||
const driver = resendEmail({ apiKey: "invalid" } as any);
|
||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -5,4 +5,3 @@ export type { IEmailDriver } from "./email";
|
||||
export { resendEmail } from "./email/resend";
|
||||
export { sesEmail } from "./email/ses";
|
||||
export { mailchannelsEmail } from "./email/mailchannels";
|
||||
export { plunkEmail } from "./email/plunk";
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
|
||||
try {
|
||||
// @ts-expect-error - this is a global variable in dev
|
||||
return is_toggled(__isDev);
|
||||
} catch (_e) {
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MaybePromise } from "bknd";
|
||||
import type { Event } from "./Event";
|
||||
import type { EventClass } from "./EventManager";
|
||||
|
||||
@@ -8,7 +7,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
|
||||
export type ListenerHandler<E extends Event<any, any>> = (
|
||||
event: E,
|
||||
slug: string,
|
||||
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
|
||||
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
|
||||
|
||||
export class EventListener<E extends Event = Event> {
|
||||
mode: ListenerMode = "async";
|
||||
|
||||
@@ -32,7 +32,6 @@ export function getFlashMessage(
|
||||
): { type: FlashMessageType; message: string } | undefined {
|
||||
const flash = getCookieValue(flash_key);
|
||||
if (flash && clear) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: .
|
||||
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
}
|
||||
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>(
|
||||
obj: T,
|
||||
keys_: readonly K[] | K[] | string[],
|
||||
keys_: readonly K[],
|
||||
): 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>>;
|
||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||
if (!keys.has(key as K)) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MaybePromise } from "core/types";
|
||||
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||
|
||||
/**
|
||||
@@ -78,37 +77,3 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
|
||||
try {
|
||||
await fn;
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (instance) {
|
||||
if (e instanceof instance) {
|
||||
return true;
|
||||
}
|
||||
// if instance given but not what expected, throw
|
||||
throw e;
|
||||
}
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -120,14 +120,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
|
||||
}
|
||||
|
||||
export function slugify(str: string): string {
|
||||
return String(str)
|
||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||
.trim() // trim leading or trailing whitespace
|
||||
.toLowerCase() // convert to lowercase
|
||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||
.replace(/-+/g, "-"); // remove consecutive hyphens
|
||||
return (
|
||||
String(str)
|
||||
.normalize("NFKD") // split accented characters into their base characters and diacritical marks
|
||||
// biome-ignore lint/suspicious/noMisleadingCharacterClass: <explanation>
|
||||
.replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
||||
.trim() // trim leading or trailing whitespace
|
||||
.toLowerCase() // convert to lowercase
|
||||
.replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
|
||||
.replace(/\s+/g, "-") // replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // remove consecutive hyphens
|
||||
);
|
||||
}
|
||||
|
||||
export function truncate(str: string, length = 50, end = "..."): string {
|
||||
|
||||
@@ -96,9 +96,6 @@ export class DataController extends Controller {
|
||||
// read entity schema
|
||||
hono.get(
|
||||
"/schema.json",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
@@ -127,9 +124,6 @@ export class DataController extends Controller {
|
||||
// read schema
|
||||
hono.get(
|
||||
"/schemas/:entity/:context?",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
@@ -167,7 +161,7 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/types",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
context: (c) => ({ module: "data" }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Retrieve data typescript definitions",
|
||||
@@ -188,9 +182,6 @@ export class DataController extends Controller {
|
||||
*/
|
||||
hono.get(
|
||||
"/info/:entity",
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (_c) => ({ module: "data" }),
|
||||
}),
|
||||
permission(DataPermissions.entityRead, {
|
||||
context: (c) => ({ entity: c.req.param("entity") }),
|
||||
}),
|
||||
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
type CompiledQuery,
|
||||
type DatabaseIntrospector,
|
||||
type Dialect,
|
||||
type Expression,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
type OnModifyForeignAction,
|
||||
type QueryResult,
|
||||
type RawBuilder,
|
||||
type SelectQueryBuilder,
|
||||
type SelectQueryNode,
|
||||
type Simplify,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
||||
import type { DB } from "bknd";
|
||||
import type { Constructor } from "core/registry/Registry";
|
||||
@@ -68,9 +70,15 @@ export type IndexSpec = {
|
||||
};
|
||||
|
||||
export type DbFunctions = {
|
||||
jsonObjectFrom: typeof jsonObjectFrom;
|
||||
jsonArrayFrom: typeof jsonArrayFrom;
|
||||
jsonBuildObject: typeof jsonBuildObject;
|
||||
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
|
||||
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
|
||||
jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
||||
obj: O,
|
||||
): RawBuilder<
|
||||
Simplify<{
|
||||
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
|
||||
export type ConnQuery = CompiledQuery | Compilable;
|
||||
|
||||
@@ -14,31 +14,27 @@ export function connectionTestSuite(
|
||||
{
|
||||
makeConnection,
|
||||
rawDialectDetails,
|
||||
disableConsoleLog: _disableConsoleLog = true,
|
||||
}: {
|
||||
makeConnection: () => MaybePromise<{
|
||||
connection: Connection;
|
||||
dispose: () => MaybePromise<void>;
|
||||
}>;
|
||||
rawDialectDetails: string[];
|
||||
disableConsoleLog?: boolean;
|
||||
},
|
||||
) {
|
||||
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
||||
if (_disableConsoleLog) {
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
}
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
describe("base", () => {
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
describe("base", async () => {
|
||||
test("pings", async () => {
|
||||
const res = await ctx.connection.ping();
|
||||
expect(res).toBe(true);
|
||||
@@ -102,54 +98,52 @@ export function connectionTestSuite(
|
||||
});
|
||||
|
||||
describe("schema", async () => {
|
||||
const makeSchema = async () => {
|
||||
const fields = [
|
||||
{
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "text",
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
name: "json",
|
||||
},
|
||||
] as const satisfies FieldSpec[];
|
||||
const { connection, dispose } = await makeConnection();
|
||||
afterAll(async () => {
|
||||
await dispose();
|
||||
});
|
||||
|
||||
let b = ctx.connection.kysely.schema.createTable("test");
|
||||
for (const field of fields) {
|
||||
// @ts-expect-error
|
||||
b = b.addColumn(...ctx.connection.getFieldSchema(field));
|
||||
}
|
||||
await b.execute();
|
||||
const fields = [
|
||||
{
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "text",
|
||||
},
|
||||
{
|
||||
type: "json",
|
||||
name: "json",
|
||||
},
|
||||
] as const satisfies FieldSpec[];
|
||||
|
||||
// add index
|
||||
await ctx.connection.kysely.schema
|
||||
.createIndex("test_index")
|
||||
.on("test")
|
||||
.columns(["id"])
|
||||
.execute();
|
||||
};
|
||||
let b = connection.kysely.schema.createTable("test");
|
||||
for (const field of fields) {
|
||||
// @ts-expect-error
|
||||
b = b.addColumn(...connection.getFieldSchema(field));
|
||||
}
|
||||
await b.execute();
|
||||
|
||||
// add index
|
||||
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
|
||||
|
||||
test("executes query", async () => {
|
||||
await makeSchema();
|
||||
await ctx.connection.kysely
|
||||
await connection.kysely
|
||||
.insertInto("test")
|
||||
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
||||
.execute();
|
||||
|
||||
const expected = { id: 1, text: "test", json: { a: 1 } };
|
||||
|
||||
const qb = ctx.connection.kysely.selectFrom("test").selectAll();
|
||||
const res = await ctx.connection.executeQuery(qb);
|
||||
const qb = connection.kysely.selectFrom("test").selectAll();
|
||||
const res = await connection.executeQuery(qb);
|
||||
expect(res.rows).toEqual([expected]);
|
||||
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);
|
||||
res.map((r) => {
|
||||
expect(r.rows).toEqual([expected]);
|
||||
@@ -161,21 +155,15 @@ export function connectionTestSuite(
|
||||
});
|
||||
|
||||
test("introspects", async () => {
|
||||
await makeSchema();
|
||||
const tables = await ctx.connection.getIntrospector().getTables({
|
||||
const tables = await connection.getIntrospector().getTables({
|
||||
withInternalKyselyTables: false,
|
||||
});
|
||||
const clean = tables.map((t) => ({
|
||||
...t,
|
||||
columns: t.columns
|
||||
.map((c) => ({
|
||||
...c,
|
||||
// ignore data type
|
||||
dataType: undefined,
|
||||
// ignore default value if "id"
|
||||
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
columns: t.columns.map((c) => ({
|
||||
...c,
|
||||
dataType: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
expect(clean).toEqual([
|
||||
@@ -188,16 +176,7 @@ export function connectionTestSuite(
|
||||
dataType: undefined,
|
||||
isNullable: false,
|
||||
isAutoIncrementing: true,
|
||||
hasDefaultValue: undefined,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
dataType: undefined,
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
{
|
||||
name: "text",
|
||||
@@ -205,26 +184,32 @@ export function connectionTestSuite(
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
comment: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
|
||||
{
|
||||
name: "test_index",
|
||||
table: "test",
|
||||
isUnique: false,
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
order: 0,
|
||||
name: "json",
|
||||
dataType: undefined,
|
||||
isNullable: true,
|
||||
isAutoIncrementing: false,
|
||||
hasDefaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(await connection.getIntrospector().getIndices()).toEqual([
|
||||
{
|
||||
name: "test_index",
|
||||
table: "test",
|
||||
isUnique: false,
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("integration", async () => {
|
||||
|
||||
@@ -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 type { Field } from "data/fields/Field";
|
||||
|
||||
// @todo: add pragmas
|
||||
export type SqliteConnectionConfig<
|
||||
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
||||
> = {
|
||||
excludeTables?: string[];
|
||||
dialect: CustomDialect;
|
||||
dialectArgs?: ConstructorParameters<CustomDialect>;
|
||||
additionalPlugins?: KyselyPlugin[];
|
||||
customFn?: Partial<DbFunctions>;
|
||||
} & (
|
||||
| {
|
||||
dialect: CustomDialect;
|
||||
dialectArgs?: ConstructorParameters<CustomDialect>;
|
||||
}
|
||||
| {
|
||||
kysely: Kysely<any>;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
|
||||
override name = "sqlite";
|
||||
|
||||
constructor(config: SqliteConnectionConfig) {
|
||||
const { excludeTables, additionalPlugins } = config;
|
||||
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
|
||||
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
|
||||
|
||||
let kysely: Kysely<any>;
|
||||
if ("dialect" in config) {
|
||||
kysely = new Kysely({
|
||||
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
|
||||
excludeTables,
|
||||
plugins,
|
||||
}).create(...(config.dialectArgs ?? [])),
|
||||
const kysely = new Kysely({
|
||||
dialect: customIntrospector(dialect, SqliteIntrospector, {
|
||||
excludeTables,
|
||||
plugins,
|
||||
});
|
||||
} else if ("kysely" in config) {
|
||||
kysely = config.kysely;
|
||||
} else {
|
||||
throw new Error("Either dialect or kysely must be provided");
|
||||
}
|
||||
}).create(...dialectArgs),
|
||||
plugins,
|
||||
});
|
||||
|
||||
super(
|
||||
kysely,
|
||||
|
||||
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
};
|
||||
}) ?? [],
|
||||
|
||||
@@ -18,7 +18,7 @@ export type LibsqlClientFns = {
|
||||
function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client {
|
||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||
const { url, authToken } = clientOrCredentials;
|
||||
return createClient({ url, authToken }) as unknown as Client;
|
||||
return createClient({ url, authToken });
|
||||
}
|
||||
|
||||
return clientOrCredentials as Client;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -136,8 +136,10 @@ export class Entity<
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
|
||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
||||
field.isFillable(context),
|
||||
);
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
@@ -189,9 +191,15 @@ export class Entity<
|
||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
getFields({
|
||||
virtual = false,
|
||||
primary = true,
|
||||
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
|
||||
return this.fields.filter((f) => {
|
||||
if (!virtual && f.isVirtual()) return false;
|
||||
if (!primary && f instanceof PrimaryField) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
addField(field: Field) {
|
||||
@@ -231,7 +239,7 @@ export class Entity<
|
||||
}
|
||||
}
|
||||
|
||||
const fields = this.getFillableFields(context, false);
|
||||
const fields = this.getFillableFields(context as any, false);
|
||||
|
||||
if (options?.ignoreUnknown !== true) {
|
||||
const field_names = fields.map((f) => f.name);
|
||||
@@ -275,7 +283,7 @@ export class Entity<
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
fields = this.getFields({ virtual: true });
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
|
||||
@@ -83,8 +83,10 @@ export class Mutator<
|
||||
}
|
||||
|
||||
// we should never get here, but just to be sure (why?)
|
||||
if (!field.isFillable(context)) {
|
||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||
if (!field.isFillable(context as any)) {
|
||||
throw new Error(
|
||||
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// transform from field
|
||||
|
||||
@@ -103,7 +103,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
validated.with = options.with;
|
||||
}
|
||||
|
||||
// add explicit joins. Implicit joins are added in `where` builder
|
||||
if (options.join && options.join.length > 0) {
|
||||
for (const entry of options.join) {
|
||||
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) => {
|
||||
if (field.includes(".")) {
|
||||
const [alias, prop] = field.split(".") as [string, string];
|
||||
// check aliases first (added joins)
|
||||
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)) {
|
||||
if (!aliases.includes(alias)) {
|
||||
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");
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
import { InvalidSearchParamsException } from "data/errors";
|
||||
import type { Entity, EntityManager, RepositoryQB } from "data/entities";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export class WithBuilder {
|
||||
static addClause(
|
||||
@@ -14,7 +13,7 @@ export class WithBuilder {
|
||||
withs: RepoQuery["with"],
|
||||
) {
|
||||
if (!withs || !isObject(withs)) {
|
||||
$console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -38,7 +37,9 @@ export class WithBuilder {
|
||||
let subQuery = relation.buildWith(entity, ref)(eb);
|
||||
if (query) {
|
||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
||||
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
|
||||
Boolean,
|
||||
) as any,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ export class WithBuilder {
|
||||
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
|
||||
let depth = 0;
|
||||
if (!withs || !isObject(withs)) {
|
||||
withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
||||
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
||||
return depth;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
|
||||
.strictObject({
|
||||
label: s.string(),
|
||||
description: s.string(),
|
||||
required: s.boolean({ default: false }),
|
||||
fillable: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||
]),
|
||||
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
||||
fillable: s.anyOf(
|
||||
[
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ["create", "update"] }), {
|
||||
title: "Context",
|
||||
uniqueItems: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
default: DEFAULT_FILLABLE,
|
||||
},
|
||||
),
|
||||
hidden: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
// @todo: tmp workaround
|
||||
@@ -103,7 +111,7 @@ export abstract class Field<
|
||||
return this.config?.default_value;
|
||||
}
|
||||
|
||||
isFillable(context?: TActionContext): boolean {
|
||||
isFillable(context?: "create" | "update"): boolean {
|
||||
if (Array.isArray(this.config.fillable)) {
|
||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||
}
|
||||
@@ -165,7 +173,7 @@ export abstract class Field<
|
||||
// @todo: add field level validation
|
||||
isValid(value: any, context: TActionContext): boolean {
|
||||
if (typeof value !== "undefined") {
|
||||
return this.isFillable(context);
|
||||
return this.isFillable(context as any);
|
||||
} else if (context === "create") {
|
||||
return !this.isRequired();
|
||||
}
|
||||
|
||||
@@ -26,12 +26,7 @@ export class JsonSchemaField<
|
||||
|
||||
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
||||
super(name, config);
|
||||
|
||||
// make sure to hand over clean json
|
||||
const schema = this.getJsonSchema();
|
||||
this.validator = new Validator(
|
||||
typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {},
|
||||
);
|
||||
this.validator = new Validator({ ...this.getJsonSchema() });
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
|
||||
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
|
||||
|
||||
switch (context) {
|
||||
case "submit":
|
||||
return Number.parseInt(value, 10);
|
||||
return Number.parseInt(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -99,6 +99,7 @@ export function fieldTestSuite(
|
||||
const _config = {
|
||||
..._requiredConfig,
|
||||
required: false,
|
||||
fillable: true,
|
||||
};
|
||||
|
||||
function fieldJson(field: Field) {
|
||||
@@ -116,10 +117,7 @@ export function fieldTestSuite(
|
||||
|
||||
expect(fieldJson(fillable)).toEqual({
|
||||
type: noConfigField.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
},
|
||||
config: _config,
|
||||
});
|
||||
|
||||
expect(fieldJson(required)).toEqual({
|
||||
@@ -150,7 +148,6 @@ export function fieldTestSuite(
|
||||
type: requiredAndDefault.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
required: true,
|
||||
default_value: config.defaultValue,
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export function getChangeSet(
|
||||
const value = _value === "" ? null : _value;
|
||||
|
||||
// normalize to null if undefined
|
||||
const newValue = field.getValue(value, "submit") ?? null;
|
||||
const newValue = field.getValue(value, "submit") || null;
|
||||
// @todo: add typing for "action"
|
||||
if (action === "create" || newValue !== data[key]) {
|
||||
acc[key] = newValue;
|
||||
|
||||
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export class SchemaManager {
|
||||
}
|
||||
|
||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||
const fields = entity.getFields(false);
|
||||
const fields = entity.getFields({ virtual: false });
|
||||
const indices = this.em.getIndicesOf(entity);
|
||||
|
||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||
|
||||
@@ -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 { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||
import type { PrimaryFieldType } from "modules";
|
||||
import type { Generated } from "kysely";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||
$parse(q.repoQuery, v, {
|
||||
@@ -16,9 +15,6 @@ const decode = (input: any, output: any) => {
|
||||
expect(parse(input)).toEqual(output);
|
||||
};
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("server/query", () => {
|
||||
test("limit & offset", () => {
|
||||
//expect(() => parse({ limit: false })).toThrow();
|
||||
|
||||
@@ -132,8 +132,6 @@ export type * from "data/entities/Entity";
|
||||
export type { EntityManager } from "data/entities/EntityManager";
|
||||
export type { SchemaManager } from "data/schema/SchemaManager";
|
||||
export type * from "data/entities";
|
||||
|
||||
// data connection
|
||||
export {
|
||||
BaseIntrospector,
|
||||
Connection,
|
||||
@@ -146,32 +144,9 @@ export {
|
||||
type ConnQuery,
|
||||
type ConnQueryResults,
|
||||
} from "data/connection";
|
||||
|
||||
// data sqlite
|
||||
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
||||
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
||||
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 {
|
||||
text,
|
||||
number,
|
||||
|
||||
@@ -71,12 +71,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
}
|
||||
|
||||
protected uploadFile<T extends FileUploadedEventData>(
|
||||
body: BodyInit,
|
||||
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
||||
opts?: {
|
||||
filename?: string;
|
||||
path?: TInput;
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
query?: Record<string, any>;
|
||||
},
|
||||
): FetchPromise<ResponseObject<T>> {
|
||||
const headers = {
|
||||
@@ -103,22 +102,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
headers,
|
||||
};
|
||||
if (opts?.path) {
|
||||
return this.request<T>(opts.path, opts?.query, {
|
||||
...init,
|
||||
body,
|
||||
method: "POST",
|
||||
});
|
||||
return this.post(opts.path, body, init);
|
||||
}
|
||||
|
||||
if (!name || name.length === 0) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
return this.request<T>(opts?.path ?? ["upload", name], opts?.query, {
|
||||
...init,
|
||||
body,
|
||||
method: "POST",
|
||||
});
|
||||
return this.post<T>(opts?.path ?? ["upload", name], body, init);
|
||||
}
|
||||
|
||||
async upload<T extends FileUploadedEventData>(
|
||||
@@ -128,7 +119,6 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
path?: TInput;
|
||||
fetcher?: ApiFetcher;
|
||||
query?: Record<string, any>;
|
||||
} = {},
|
||||
) {
|
||||
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(
|
||||
@@ -165,14 +155,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
opts?: {
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
fetcher?: typeof fetch;
|
||||
overwrite?: boolean;
|
||||
},
|
||||
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
|
||||
const query = opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : undefined;
|
||||
return this.upload(item, {
|
||||
...opts,
|
||||
path: ["entity", entity, id, field],
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
|
||||
|
||||
export async function adapterTestSuite(
|
||||
testRunner: TestRunner,
|
||||
_adapter: StorageAdapter | (() => StorageAdapter),
|
||||
adapter: StorageAdapter,
|
||||
file: File | BunFile,
|
||||
opts?: {
|
||||
retries?: number;
|
||||
@@ -25,12 +25,7 @@ export async function adapterTestSuite(
|
||||
const _filename = randomString(10);
|
||||
const filename = `${_filename}.png`;
|
||||
|
||||
const getAdapter = (
|
||||
typeof _adapter === "function" ? _adapter : () => _adapter
|
||||
) as () => StorageAdapter;
|
||||
|
||||
await test("puts an object", async () => {
|
||||
const adapter = getAdapter();
|
||||
objects = (await adapter.listObjects()).length;
|
||||
const result = await adapter.putObject(filename, file as unknown as File);
|
||||
expect(result).toBeDefined();
|
||||
@@ -43,7 +38,6 @@ export async function adapterTestSuite(
|
||||
});
|
||||
|
||||
await test("lists objects", async () => {
|
||||
const adapter = getAdapter();
|
||||
const length = await retry(
|
||||
() => adapter.listObjects().then((res) => res.length),
|
||||
(length) => length > objects,
|
||||
@@ -55,12 +49,10 @@ export async function adapterTestSuite(
|
||||
});
|
||||
|
||||
await test("file exists", async () => {
|
||||
const adapter = getAdapter();
|
||||
expect(await adapter.objectExists(filename)).toBe(true);
|
||||
});
|
||||
|
||||
await test("gets an object", async () => {
|
||||
const adapter = getAdapter();
|
||||
const res = await adapter.getObject(filename, new Headers());
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
@@ -70,7 +62,6 @@ export async function adapterTestSuite(
|
||||
if (options.testRange) {
|
||||
await test("handles range request - partial content", async () => {
|
||||
const headers = new Headers({ Range: "bytes=0-99" });
|
||||
const adapter = getAdapter();
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
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 () => {
|
||||
const headers = new Headers({ Range: "bytes=-100" });
|
||||
const adapter = getAdapter();
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
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 () => {
|
||||
const headers = new Headers({ Range: "bytes=invalid" });
|
||||
const adapter = getAdapter();
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||
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 () => {
|
||||
const adapter = getAdapter();
|
||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||
type: file.type, // image/png
|
||||
size: file.size,
|
||||
@@ -103,7 +91,6 @@ export async function adapterTestSuite(
|
||||
});
|
||||
|
||||
await test("deletes an object", async () => {
|
||||
const adapter = getAdapter();
|
||||
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
||||
|
||||
if (opts?.skipExistsAfterDelete !== true) {
|
||||
|
||||
@@ -10,19 +10,16 @@ export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends B
|
||||
? BkndModeConfig<Args, AdapterConfig>
|
||||
: never;
|
||||
|
||||
export function code<
|
||||
Config extends BkndConfig,
|
||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
||||
>(codeConfig: CodeMode<Config>): BkndConfig<Args> {
|
||||
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
|
||||
return {
|
||||
...codeConfig,
|
||||
...config,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
plugins,
|
||||
isProd,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(codeConfig, args);
|
||||
} = await makeModeConfig(config, args);
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
|
||||
$console.warn("You should not set a different mode than `db` when using code mode");
|
||||
|
||||
+23
-24
@@ -1,6 +1,6 @@
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
||||
import { getDefaultConfig, type MaybePromise, type Merge } from "bknd";
|
||||
import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
|
||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
|
||||
@@ -9,7 +9,7 @@ export type BkndHybridModeOptions = {
|
||||
* Reader function to read the configuration from the file system.
|
||||
* This is required for hybrid mode to work.
|
||||
*/
|
||||
reader?: (path: string) => MaybePromise<string | object>;
|
||||
reader?: (path: string) => MaybePromise<string>;
|
||||
/**
|
||||
* Provided secrets to be merged into the configuration
|
||||
*/
|
||||
@@ -23,36 +23,42 @@ export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends
|
||||
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
|
||||
: never;
|
||||
|
||||
export function hybrid<
|
||||
Config extends BkndConfig,
|
||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
||||
>(hybridConfig: HybridMode<Config>): BkndConfig<Args> {
|
||||
export function hybrid<Args>({
|
||||
configFilePath = "bknd-config.json",
|
||||
...rest
|
||||
}: HybridBkndConfig<Args>): BkndConfig<Args> {
|
||||
return {
|
||||
...hybridConfig,
|
||||
...rest,
|
||||
config: undefined,
|
||||
app: async (args) => {
|
||||
const {
|
||||
config: appConfig,
|
||||
isProd,
|
||||
plugins,
|
||||
syncSchemaOptions,
|
||||
} = await makeModeConfig(hybridConfig, args);
|
||||
|
||||
const configFilePath = appConfig.configFilePath ?? "bknd-config.json";
|
||||
} = await makeModeConfig(
|
||||
{
|
||||
...rest,
|
||||
configFilePath,
|
||||
},
|
||||
args,
|
||||
);
|
||||
|
||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") {
|
||||
$console.warn("You should not set a different mode than `db` when using hybrid mode");
|
||||
}
|
||||
invariant(
|
||||
typeof appConfig.reader === "function",
|
||||
"You must set a `reader` option when using hybrid mode",
|
||||
"You must set the `reader` option when using hybrid mode",
|
||||
);
|
||||
|
||||
const fileContent = await appConfig.reader?.(configFilePath);
|
||||
let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent;
|
||||
if (!fileConfig) {
|
||||
$console.warn("No config found, using default config");
|
||||
fileConfig = getDefaultConfig();
|
||||
await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2));
|
||||
let fileConfig: ModuleConfigs;
|
||||
try {
|
||||
fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
|
||||
} catch (e) {
|
||||
const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
|
||||
await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
|
||||
fileConfig = defaultConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -74,13 +80,6 @@ export function hybrid<
|
||||
skipValidation: isProd,
|
||||
// secrets are required for hybrid mode
|
||||
secrets: appConfig.secrets,
|
||||
onModulesBuilt: async (ctx) => {
|
||||
if (ctx.flags.sync_required && !isProd && syncSchemaOptions.force) {
|
||||
$console.log("[hybrid] syncing schema");
|
||||
await ctx.em.schema().sync(syncSchemaOptions);
|
||||
}
|
||||
await appConfig?.options?.manager?.onModulesBuilt?.(ctx);
|
||||
},
|
||||
...appConfig?.options?.manager,
|
||||
},
|
||||
},
|
||||
|
||||
+10
-24
@@ -1,7 +1,7 @@
|
||||
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
|
||||
import { syncTypes, syncConfig } from "bknd/plugins";
|
||||
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
|
||||
import { $console } from "bknd/utils";
|
||||
import { invariant, $console } from "bknd/utils";
|
||||
|
||||
export type BkndModeOptions = {
|
||||
/**
|
||||
@@ -56,14 +56,6 @@ export type BkndModeConfig<Args = any, Additional = {}> = BkndConfig<
|
||||
Merge<BkndModeOptions & Additional>
|
||||
>;
|
||||
|
||||
function _isProd() {
|
||||
try {
|
||||
return process.env.NODE_ENV === "production";
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeModeConfig<
|
||||
Args = any,
|
||||
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
|
||||
@@ -77,24 +69,25 @@ export async function makeModeConfig<
|
||||
|
||||
if (typeof config.isProduction !== "boolean") {
|
||||
$console.warn(
|
||||
"You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to",
|
||||
_isProd(),
|
||||
"You should set `isProduction` option when using managed modes to prevent accidental issues",
|
||||
);
|
||||
}
|
||||
|
||||
let needsWriter = false;
|
||||
invariant(
|
||||
typeof config.writer === "function",
|
||||
"You must set the `writer` option when using managed modes",
|
||||
);
|
||||
|
||||
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
||||
|
||||
const isProd = config.isProduction ?? _isProd();
|
||||
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
|
||||
const isProd = config.isProduction;
|
||||
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const syncSchemaOptions =
|
||||
typeof config.syncSchema === "object"
|
||||
? config.syncSchema
|
||||
: {
|
||||
force: syncFallback,
|
||||
drop: syncFallback,
|
||||
force: config.syncSchema !== false,
|
||||
drop: true,
|
||||
};
|
||||
|
||||
if (!isProd) {
|
||||
@@ -102,7 +95,6 @@ export async function makeModeConfig<
|
||||
if (plugins.some((p) => p.name === "bknd-sync-types")) {
|
||||
throw new Error("You have to unregister the `syncTypes` plugin");
|
||||
}
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncTypes({
|
||||
enabled: true,
|
||||
@@ -122,7 +114,6 @@ export async function makeModeConfig<
|
||||
if (plugins.some((p) => p.name === "bknd-sync-config")) {
|
||||
throw new Error("You have to unregister the `syncConfig` plugin");
|
||||
}
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncConfig({
|
||||
enabled: true,
|
||||
@@ -151,7 +142,6 @@ export async function makeModeConfig<
|
||||
.join(".");
|
||||
}
|
||||
|
||||
needsWriter = true;
|
||||
plugins.push(
|
||||
syncSecrets({
|
||||
enabled: true,
|
||||
@@ -184,10 +174,6 @@ export async function makeModeConfig<
|
||||
}
|
||||
}
|
||||
|
||||
if (needsWriter && typeof config.writer !== "function") {
|
||||
$console.warn("You must set a `writer` function, attempts to write will fail");
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
isProd,
|
||||
|
||||
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
|
||||
verbosity?: Verbosity;
|
||||
};
|
||||
|
||||
const debug_modules = env("modules_debug", false);
|
||||
const debug_modules = env("modules_debug");
|
||||
|
||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||
export class ModuleManagerConfigUpdateEvent<
|
||||
@@ -223,7 +223,7 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
extractSecrets() {
|
||||
const moduleConfigs = JSON.parse(JSON.stringify(this.configs()));
|
||||
const moduleConfigs = structuredClone(this.configs());
|
||||
const secrets = { ...this.options?.secrets };
|
||||
const extractedKeys: string[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mark, stripMark, $console, s, setPath } from "bknd/utils";
|
||||
import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils";
|
||||
import { BkndError } from "core/errors";
|
||||
import * as $diff from "core/object/diff";
|
||||
import type { Connection } from "data/connection";
|
||||
@@ -290,12 +290,13 @@ export class DbModuleManager extends ModuleManager {
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
} else if (e instanceof TransformPersistFailedException) {
|
||||
$console.error("ModuleManager: Cannot save invalid config");
|
||||
this.revertModules();
|
||||
throw e;
|
||||
} else {
|
||||
if (e instanceof TransformPersistFailedException) {
|
||||
$console.error("ModuleManager: Cannot save invalid config");
|
||||
}
|
||||
$console.error("ModuleManager: Aborting");
|
||||
await this.revertModules();
|
||||
this.revertModules();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { transformObject } from "bknd/utils";
|
||||
import type { Kysely } from "kysely";
|
||||
import { set } from "lodash-es";
|
||||
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
||||
|
||||
export type MigrationContext = {
|
||||
db: Kysely<any>;
|
||||
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
|
||||
return config;
|
||||
},
|
||||
},
|
||||
{
|
||||
// change field.config.fillable to only "create" and "update"
|
||||
version: 11,
|
||||
up: async (config: InitialModuleConfigs) => {
|
||||
const { data, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
data: {
|
||||
...data,
|
||||
entities: transformObject(data?.entities ?? {}, (entity) => {
|
||||
return {
|
||||
...entity,
|
||||
fields: transformObject(entity?.fields ?? {}, (field) => {
|
||||
const fillable = field!.config?.fillable;
|
||||
if (!fillable || typeof fillable === "boolean") return field;
|
||||
return { ...field, config: { ...field!.config, fillable: true } };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||
|
||||
@@ -33,5 +33,3 @@ export const schemaRead = new Permission(
|
||||
);
|
||||
export const build = new Permission("system.build");
|
||||
export const mcp = new Permission("system.mcp");
|
||||
export const info = new Permission("system.info");
|
||||
export const openapi = new Permission("system.openapi");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user