Compare commits

..

3 Commits

Author SHA1 Message Date
dswbx 5c3d3d763e move sort plugin to data plugins 2025-11-26 10:26:58 +01:00
dswbx 407d103a5f fix: remove test.only 2025-11-26 10:12:39 +01:00
dswbx 2fd8b9f9a0 feat: add sort plugin
- Introduced a new sort plugin that allows sorting of entities based on a specified field.
- Added API endpoints for reordering items and recalculating sort order.
- Implemented tests to ensure correct functionality of sorting and reordering features.
2025-11-26 10:08:30 +01:00
173 changed files with 3463 additions and 4623 deletions
+1 -16
View File
@@ -9,21 +9,6 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -39,7 +24,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
run: bun install #--linker=hoisted run: bun install
- name: Build - name: Build
working-directory: ./app working-directory: ./app
+77 -169
View File
@@ -1,202 +1,110 @@
# Functional Source License, Version 1.1, MIT Future License
Apache License ## Abbreviation
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION FSL-1.1-MIT
1. Definitions. ## Notice
"License" shall mean the terms and conditions for use, reproduction, Copyright 2025 Dennis Senn
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by ## Terms and Conditions
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all ### Licensor ("We")
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity The party offering the Software under these Terms and Conditions.
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, ### The Software
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical The "Software" is each version of the software that we make available under
transformation or translation of a Source form, including but these Terms and Conditions, as indicated by our inclusion of these Terms and
not limited to compiled object code, generated documentation, Conditions with the Software.
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or ### License Grant
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object Subject to your compliance with this License Grant and the Patents,
form, that is based on (or derived from) the Work and for which the Redistribution and Trademark clauses below, we hereby grant you the right to
editorial revisions, annotations, elaborations, or other modifications use, copy, modify, create derivative works, publicly perform, publicly display
represent, as a whole, an original work of authorship. For the purposes and redistribute the Software for any Permitted Purpose identified below.
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including ### Permitted Purpose
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
on behalf of whom a Contribution has been received by Licensor and means making the Software available to others in a commercial product or
subsequently incorporated within the Work. service that:
2. Grant of Copyright License. Subject to the terms and conditions of 1. substitutes for the Software;
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of 2. substitutes for any other product or service we offer using the Software
this License, each Contributor hereby grants to You a perpetual, that exists as of the date we make the Software available; or
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the 3. offers the same or substantially similar functionality as the Software.
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Permitted Purposes specifically include using the Software:
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices 1. for your internal use and access;
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works 2. for non-commercial education;
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its 3. for non-commercial research; and
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and 4. in connection with professional services that you provide to a licensee
may provide additional or different license terms and conditions using the Software in accordance with these Terms and Conditions.
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, ### Patents
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade To the extent your use for a Permitted Purpose would necessarily infringe our
names, trademarks, service marks, or product names of the Licensor, patents, the license grant above includes a license under our patents. If you
except as required for reasonable and customary use in describing the make a claim against any party that the Software infringes or contributes to
origin of the Work and reproducing the content of the NOTICE file. the infringement of any patent, then your patent license to the Software ends
immediately.
7. Disclaimer of Warranty. Unless required by applicable law or ### Redistribution
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, The Terms and Conditions apply to all copies, modifications and derivatives of
whether in tort (including negligence), contract, or otherwise, the Software.
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing If you redistribute any copies, modifications or derivatives of the Software,
the Work or Derivative Works thereof, You may choose to offer, you must include a copy of or a link to these Terms and Conditions and not
and charge a fee for, acceptance of support, warranty, indemnity, remove any copyright notices provided in or with the Software.
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS ### Disclaimer
APPENDIX: How to apply the Apache License to your work. THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
To apply the Apache License to your work, attach the following IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
boilerplate notice, with the fields enclosed by brackets "[]" SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
replaced with your own identifying information. (Don't include EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Dennis Senn ### Trademarks
Licensed under the Apache License, Version 2.0 (the "License"); Except for displaying the License Details and identifying us as the origin of
you may not use this file except in compliance with the License. the Software, you have no right under these Terms and Conditions to use our
You may obtain a copy of the License at trademarks, trade names, service marks or product names.
http://www.apache.org/licenses/LICENSE-2.0 ## Grant of Future License
Unless required by applicable law or agreed to in writing, software We hereby irrevocably grant you an additional license to use the Software under
distributed under the License is distributed on an "AS IS" BASIS, the MIT license that is effective on the second anniversary of the date we make
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. the Software available. On or after that date, you may use the Software under
See the License for the specific language governing permissions and the MIT license, in which case the following will apply:
limitations under the License.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata * Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS) * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. * **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
+45 -55
View File
@@ -1,76 +1,66 @@
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test"; import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter"; import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
describe("makeConfig", () => { it("makes config", async () => {
it("returns empty config for empty inputs", async () => { expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
const cases: Array<Parameters<typeof adapter.makeConfig>> = [ expect(
[{}], omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
[{}, { env: { TEST: "test" } }], ).toEqual({});
];
for (const args of cases) { // merges everything returned from `app` with the config
const cfg = await adapter.makeConfig(...(args as any)); expect(
expect(stripConnection(cfg)).toEqual({}); omitKeys(
} await adapter.makeConfig(
}); { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
it("merges app output into config", async () => { ),
const cfg = await adapter.makeConfig( ["connection"],
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, ),
{ env: { TEST: "test" } }, ).toEqual({
); config: { server: { cors: { origin: "test" } } },
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
}); });
}); });
it("allows all properties in app() result", async () => { it("allows all properties in app function", async () => {
const called = mock(() => null); const called = mock(() => null);
const config = await adapter.makeConfig(
const cfg = await adapter.makeConfig( {
{ app: (env) => ({
app: (env) => ({ connection: { url: "test" },
connection: { url: "test" }, config: { server: { cors: { origin: "test" } } },
config: { server: { cors: { origin: "test" } } }, options: {
options: { mode: "db" as const }, mode: "db",
onBuilt: () => { },
called(); onBuilt: () => {
expect(env).toEqual({ foo: "bar" }); called();
}, expect(env).toEqual({ foo: "bar" });
}), },
}, }),
{ foo: "bar" }, },
{ foo: "bar" },
); );
expect(config.connection).toEqual({ url: "test" });
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
});
expect(cfg.connection).toEqual({ url: "test" }); adapterTestSuite(bunTestRunner, {
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp, makeApp: adapter.createFrameworkApp,
label: "framework app", label: "framework app",
}); });
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp, makeApp: adapter.createRuntimeApp,
label: "runtime app", label: "runtime app",
}); });
});
}); });
+10 -33
View File
@@ -1,7 +1,7 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it, mock } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils"; import { getFileFromContext, isFile, isReadableStream } from "core/utils";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
const res = await mockedBackend.request("/api/media/file/" + name); const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res); await Bun.write(path, res);
const file = Bun.file(path); const file = await Bun.file(path);
expect(file.size).toBeGreaterThan(0); expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png"); expect(file.type).toBe("image/png");
await file.delete(); await file.delete();
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
expect(isReadableStream(res.body)).toBe(true); expect(isReadableStream(res.body)).toBe(true);
expect(isReadableStream(res.res.body)).toBe(true); expect(isReadableStream(res.res.body)).toBe(true);
const blob = (await res.res.blob()) as File; const blob = await res.res.blob();
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -113,7 +113,7 @@ describe("MediaApi", () => {
const res = await api.getFileStream(name); const res = await api.getFileStream(name);
expect(isReadableStream(res)).toBe(true); 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(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");
@@ -154,38 +154,15 @@ describe("MediaApi", () => {
} }
// upload via readable from bun // upload via readable from bun
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response // upload via readable from response
{ {
const response = (await mockedBackend.request(url)) as Response; const response = (await mockedBackend.request(url)) as Response;
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png"); await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
} }
}); });
it("should add overwrite query for entity upload", async (c) => {
const call = mock(() => null);
const hono = new Hono().post(
"/api/media/entity/:entity/:id/:field",
jsc("query", s.object({ overwrite: s.boolean().optional() })),
async (c) => {
const { overwrite } = c.req.valid("query");
expect(overwrite).toBe(true);
call();
return c.json({ ok: true });
},
);
const api = new MediaApi(
{
upload_fetcher: hono.request,
},
hono.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
overwrite: true,
});
expect(res.ok).toBe(true);
expect(call).toHaveBeenCalled();
});
}); });
+1 -5
View File
@@ -1,12 +1,8 @@
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd"; import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper"; import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager"; import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) { async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({ const app = internalCreateApp({
+1 -5
View File
@@ -1,11 +1,7 @@
import { AppEvents } from "App"; import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, mock } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/** /**
* - [x] system_config * - [x] system_config
@@ -1,12 +1,8 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission"; import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role"; import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils"; import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard( function createGuard(
permissionNames: string[], permissionNames: string[],
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog); beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog); afterAll(() => enableConsoleLog());
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) { async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({ const app = createApp({
@@ -1,40 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from "bun:test";
import { SystemController } from "modules/server/SystemController";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
async function makeApp(config: Partial<CreateAppConfig> = {}) {
const app = createApp(config);
await app.build();
return app;
}
describe.skip("SystemController", () => {
it("...", async () => {
const app = await makeApp();
const controller = new SystemController(app);
const hono = controller.getController();
console.log(getPermissionRoutes(hono));
});
});
@@ -1,41 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
-171
View File
@@ -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();
});
});
-85
View File
@@ -1,85 +0,0 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
@@ -124,81 +124,6 @@ describe("[Repository]", async () => {
.then((r) => [r.count, r.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
});
}); });
describe("[data] Repository (Events)", async () => { describe("[data] Repository (Events)", async () => {
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
@@ -1,15 +1,9 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares"; import { auth } from "../../src/modules/middlewares";
import { import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
mergeObject,
randomString,
secureRandomString,
withDisabledConsole,
} from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import type { AppAuthSchema } from "auth/auth-schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -68,12 +62,12 @@ const configs = {
}, },
}; };
function createAuthApp(config?: Partial<AppAuthSchema>) { function createAuthApp() {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
config: { config: {
auth: mergeObject(configs.auth, config ?? {}), auth: configs.auth,
}, },
}); });
@@ -138,16 +132,6 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
return { res, data }; return { res, data };
}, },
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
const res = (await app.server.request("/api/auth/password/register", {
method: "POST",
headers: headers(),
body: body(user),
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => { me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", { const res = (await app.server.request("/api/auth/me", {
method: "GET", method: "GET",
@@ -261,61 +245,4 @@ describe("integration auth", () => {
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }
}); });
it("should register users with default role", async () => {
const app = createAuthApp({ default_role_register: "guest" });
await app.build();
const $fns = fns(app);
// takes default role
expect(
await app
.createUser({
email: "test@bknd.io",
password: "12345678",
})
.then((r) => r.role),
).toBe("guest");
// throws error if role doesn't exist
expect(
app.createUser({
email: "test@bknd.io",
password: "12345678",
role: "doesnt exist",
}),
).rejects.toThrow();
// takes role if provided
expect(
await app
.createUser({
email: "test2@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.role),
).toBe("admin");
// registering with role is not allowed
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.res.ok),
).toBe(false);
// takes default role
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
})
.then((r) => r.data.user.role),
).toBe("guest");
});
}); });
+1 -85
View File
@@ -8,10 +8,9 @@ import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import * as proto from "data/prototype";
beforeAll(() => { beforeAll(() => {
disableConsoleLog(); //disableConsoleLog();
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
}); });
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -129,87 +128,4 @@ describe("MediaController", () => {
expect(destFile.exists()).resolves.toBe(true); expect(destFile.exists()).resolves.toBe(true);
await destFile.delete(); await destFile.delete();
}); });
test("entity upload with max_items and overwrite", async () => {
const app = createApp({
config: {
media: mergeObject(
{
enabled: true,
adapter: {
type: "local",
config: {
path: assetsTmpPath,
},
},
},
{},
),
data: {
entities: {
posts: proto
.entity("posts", {
title: proto.text(),
cover: proto.medium(),
})
.toJSON(),
},
},
},
});
await app.build();
// create a post first
const createRes = await app.server.request("/api/data/entity/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test Post" }),
});
expect(createRes.status).toBe(201);
const { data: post } = (await createRes.json()) as any;
const file = Bun.file(path);
const uploadedFiles: string[] = [];
// upload first file to entity (should succeed)
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res1.status).toBe(201);
const result1 = (await res1.json()) as any;
uploadedFiles.push(result1.name);
// upload second file without overwrite (should fail - max_items reached)
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
method: "POST",
body: file,
});
expect(res2.status).toBe(400);
const result2 = (await res2.json()) as any;
expect(result2.error).toContain("Max items");
// upload third file with overwrite=true (should succeed and delete old file)
const res3 = await app.server.request(
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
{
method: "POST",
body: file,
},
);
expect(res3.status).toBe(201);
const result3 = (await res3.json()) as any;
uploadedFiles.push(result3.name);
// verify old file was deleted from storage
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
expect(await oldFile.exists()).toBe(false);
// verify new file exists
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
expect(await newFile.exists()).toBe(true);
// cleanup
await newFile.delete();
});
}); });
+12 -28
View File
@@ -10,6 +10,12 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth); moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext; let ctx: ModuleBuildContext;
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
await auth.build(); await auth.build();
const oldConfig = auth.toJSON(true); const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true); await auth.schema().patch("enabled", true);
await auth.build(); await auth.build();
const newConfig = auth.toJSON(true); const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
}); });
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -223,32 +235,4 @@ describe("AppAuth", () => {
} }
} }
}); });
test("default role for registration must be a valid role", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "123456",
},
allow_register: true,
roles: {
guest: {
is_default: true,
},
},
},
},
});
await app.build();
const auth = app.module.auth;
// doesn't allow invalid role
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
// allows valid role
await auth.schema().patch("default_role_register", "guest");
expect(auth.toJSON().default_role_register).toBe("guest");
});
}); });
+1 -5
View File
@@ -1,14 +1,10 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia"; import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppMedia", () => { describe("AppMedia", () => {
test.skip("...", () => { test.skip("...", () => {
+1 -5
View File
@@ -1,11 +1,7 @@
import { it, expect, describe, beforeAll, afterAll } from "bun:test"; import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager"; import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations"; import { TABLE_NAME } from "modules/db/migrations";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("DbModuleManager", () => { describe("DbModuleManager", () => {
it("should extract secrets", async () => { it("should extract secrets", async () => {
+12 -1
View File
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(() => disableConsoleLog()); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
}, },
}, },
} as any; } as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely; const db = dummyConnection.kysely;
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs())); //expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
} }
override async build() { override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value); throw new Error("value must be positive, given: " + this.config.value);
} }
@@ -288,6 +296,9 @@ describe("ModuleManager", async () => {
} }
} }
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => { test("it builds", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection); const mm = new TestModuleManager(dummyConnection);
+6 -77
View File
@@ -2,8 +2,6 @@ import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -27,18 +25,7 @@ const define = {
if (clean) { if (clean) {
console.info("Cleaning dist (w/o static)"); console.info("Cleaning dist (w/o static)");
// Cross-platform clean: remove all files/folders in dist except static await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
const distPath = join(import.meta.dir, "dist");
try {
const entries = readdirSync(distPath);
for (const entry of entries) {
if (entry === "static") continue;
const entryPath = join(distPath, entry);
rmSync(entryPath, { recursive: true, force: true });
}
} catch (e) {
// dist may not exist yet, ignore
}
} }
let types_running = false; let types_running = false;
@@ -96,8 +83,7 @@ async function buildApi() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves watch,
watch: false,
define, define,
entry: [ entry: [
"src/index.ts", "src/index.ts",
@@ -135,7 +121,7 @@ async function buildUi() {
const base = { const base = {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
define, define,
external: [ external: [
...external, ...external,
@@ -194,15 +180,12 @@ async function buildUiElements() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
define, define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
"ui/client", "ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
@@ -239,7 +222,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
return { return {
minify, minify,
sourcemap, sourcemap,
watch: false, watch,
entry: [`src/adapter/${adapter}/index.ts`], entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"], format: ["esm"],
platform: "neutral", platform: "neutral",
@@ -282,11 +265,6 @@ async function buildAdapters() {
// specific adatpers // specific adatpers
tsup.build(baseConfig("react-router")), tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build( tsup.build(
baseConfig("bun", { baseConfig("bun", {
external: [/^bun\:.*/], external: [/^bun\:.*/],
@@ -319,11 +297,6 @@ async function buildAdapters() {
platform: "node", platform: "node",
}), }),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({ tsup.build({
...baseConfig("node"), ...baseConfig("node"),
platform: "node", platform: "node",
@@ -354,48 +327,4 @@ async function buildAdapters() {
]); ]);
} }
async function buildAll() { await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
}
// initial build
await buildAll();
// custom watcher since tsup's watch is broken in 8.3.5+
if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
const rebuild = async () => {
if (isBuilding) return;
isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try {
await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
}
isBuilding = false;
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
// watch src directory recursively
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
if (!filename) return;
// ignore non-source files
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild();
});
// keep process alive
await new Promise(() => {});
}
+4 -23
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.20.0", "version": "0.19.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -46,7 +46,7 @@
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
"docs:build-assets": "bun internal/docs.build-assets.ts" "docs:build-assets": "bun internal/docs.build-assets.ts"
}, },
"license": "Apache-2.0", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -63,9 +63,9 @@
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"fast-xml-parser": "^5.3.1", "fast-xml-parser": "^5.3.1",
"hono": "4.10.4", "hono": "4.10.4",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.10.1", "jsonv-ts": "0.9.3",
"kysely": "0.28.8", "kysely": "0.28.8",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -99,7 +99,6 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/pg": "^8.15.6",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.0",
@@ -108,21 +107,17 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"commander": "^14.0.2", "commander": "^14.0.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"errore": "^0.14.0",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22", "libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
"miniflare": "^4.20251011.2", "miniflare": "^4.20251011.2",
"open": "^10.2.0", "open": "^10.2.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"postgres": "^3.4.7",
"posthog-js-lite": "^3.6.0", "posthog-js-lite": "^3.6.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -130,7 +125,6 @@
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0", "react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10", "sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -254,21 +248,11 @@
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.js" "require": "./dist/adapter/astro/index.js"
}, },
"./adapter/sveltekit": {
"types": "./dist/types/adapter/sveltekit/index.d.ts",
"import": "./dist/adapter/sveltekit/index.js",
"require": "./dist/adapter/sveltekit/index.js"
},
"./adapter/aws": { "./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts", "types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js", "import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js" "require": "./dist/adapter/aws/index.js"
}, },
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./dist/main.css": "./dist/ui/main.css", "./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css", "./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json", "./dist/manifest.json": "./dist/static/.vite/manifest.json",
@@ -286,7 +270,6 @@
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"], "adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"], "adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"], "adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"] "adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
} }
}, },
@@ -316,8 +299,6 @@
"remix", "remix",
"react-router", "react-router",
"astro", "astro",
"sveltekit",
"svelte",
"bun", "bun",
"node" "node"
] ]
+1 -1
View File
@@ -61,7 +61,7 @@ export class Api {
private token?: string; private token?: string;
private user?: TApiUser; private user?: TApiUser;
private verified = false; private verified = false;
public token_transport: "header" | "cookie" | "none" = "header"; private token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi; public system!: SystemApi;
public data!: DataApi; public data!: DataApi;
-153
View File
@@ -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,
};
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";
-136
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { describe } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
-1
View File
@@ -1 +0,0 @@
export * from "./sveltekit.adapter";
@@ -1,16 +0,0 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as sveltekit from "./sveltekit.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("sveltekit adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: (c, a) => sveltekit.getApp(c as any, a ?? ({} as any)),
makeHandler: (c, a) => (request: Request) =>
sveltekit.serve(c as any, a ?? ({} as any))({ request }),
});
});
@@ -1,33 +0,0 @@
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
type TSvelteKit = {
request: Request;
};
export type SvelteKitBkndConfig<Env> = Pick<RuntimeBkndConfig<Env>, "adminOptions">;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export async function getApp<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return await createRuntimeApp(config, args);
}
/**
* Create request handler for hooks.server.ts
* @param config - bknd configuration
* @param args - environment variables (use $env/dynamic/private for universal runtime support)
*/
export function serve<Env>(
config: SvelteKitBkndConfig<Env> = {} as SvelteKitBkndConfig<Env>,
args: Env,
) {
return async (fnArgs: TSvelteKit) => {
return (await getApp(config, args)).fetch(fnArgs.request);
};
}
+1 -29
View File
@@ -46,22 +46,6 @@ export class AppAuth extends Module<AppAuthSchema> {
to.strategies!.password!.enabled = true; to.strategies!.password!.enabled = true;
} }
if (to.default_role_register && to.default_role_register?.length > 0) {
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
if (!valid_to_role) {
const msg = `Default role for registration not found: ${to.default_role_register}`;
// if changing to a new value
if (from.default_role_register !== to.default_role_register) {
throw new Error(msg);
}
// resetting gracefully, since role doesn't exist anymore
$console.warn(`${msg}, resetting to undefined`);
to.default_role_register = undefined;
}
}
return to; return to;
} }
@@ -98,7 +82,6 @@ export class AppAuth extends Module<AppAuthSchema> {
this._authenticator = new Authenticator(strategies, new AppUserPool(this), { this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
jwt: this.config.jwt, jwt: this.config.jwt,
cookie: this.config.cookie, cookie: this.config.cookie,
default_role_register: this.config.default_role_register,
}); });
this.registerEntities(); this.registerEntities();
@@ -188,20 +171,10 @@ export class AppAuth extends Module<AppAuthSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser({ async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
email,
password,
role,
...additional
}: CreateUserPayload): Promise<DB["users"]> {
if (!this.enabled) { if (!this.enabled) {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
if (role) {
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
throw new Error(`Role "${role}" not found`);
}
}
const strategy = "password" as const; const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
@@ -210,7 +183,6 @@ export class AppAuth extends Module<AppAuthSchema> {
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({ const { data: created } = await mutator.insertOne({
...(additional as any), ...(additional as any),
role: role || this.config.default_role_register || undefined,
email, email,
strategy, strategy,
strategy_value, strategy_value,
+20 -28
View File
@@ -13,7 +13,6 @@ import {
InvalidSchemaError, InvalidSchemaError,
transformObject, transformObject,
mcpTool, mcpTool,
$console,
} from "bknd/utils"; } from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -211,7 +210,7 @@ export class AuthController extends Controller {
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => { const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined; let user: DB["users"] | undefined = undefined;
if (params.id) { if (params.id) {
const { data } = await this.userRepo.findId(params.id); const { data } = await this.userRepo.findId(params.id);
user = data; user = data;
@@ -226,33 +225,26 @@ export class AuthController extends Controller {
}; };
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
try { mcp.tool(
const actions = this.auth.authenticator.strategy("password").getActions(); "auth_user_create",
if (actions.create) { {
const schema = actions.create.schema; description: "Create a new user",
mcp.tool( inputSchema: s.object({
"auth_user_create", email: s.string({ format: "email" }),
{ password: s.string({ minLength: 8 }),
description: "Create a new user", role: s
inputSchema: s.object({ .string({
...schema.properties, enum: roles.length > 0 ? roles : undefined,
role: s })
.string({ .optional(),
enum: roles.length > 0 ? roles : undefined, }),
}) },
.optional(), async (params, c) => {
}), await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
},
async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
return c.json(await this.auth.createUser(params)); return c.json(await this.auth.createUser(params));
}, },
); );
}
} catch (e) {
$console.warn("error creating auth_user_create tool", e);
}
mcp.tool( mcp.tool(
"auth_user_token", "auth_user_token",
-1
View File
@@ -51,7 +51,6 @@ export const authConfigSchema = $object(
basepath: s.string({ default: "/api/auth" }), basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }), entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(), allow_register: s.boolean({ default: true }).optional(),
default_role_register: s.string().optional(),
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
strategies: $record( strategies: $record(
@@ -74,7 +74,6 @@ export const jwtConfig = s.strictObject(
export const authenticatorConfig = s.object({ export const authenticatorConfig = s.object({
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
default_role_register: s.string().optional(),
}); });
type AuthConfig = s.Static<typeof authenticatorConfig>; type AuthConfig = s.Static<typeof authenticatorConfig>;
@@ -165,13 +164,9 @@ export class Authenticator<
if (!("strategy_value" in profile)) { if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value"); throw new InvalidConditionsException("Profile must have a strategy value");
} }
if ("role" in profile) {
throw new InvalidConditionsException("Role cannot be provided during registration");
}
const user = await this.userPool.create(strategy.getName(), { const user = await this.userPool.create(strategy.getName(), {
...profile, ...profile,
role: this.config.default_role_register,
strategy_value: profile.strategy_value, strategy_value: profile.strategy_value,
}); });
@@ -10,7 +10,6 @@ const schema = s
.object({ .object({
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }), hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
rounds: s.number({ minimum: 1, maximum: 10 }).optional(), rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
minLength: s.number({ default: 8, minimum: 1 }).optional(),
}) })
.strict(); .strict();
@@ -38,7 +37,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
format: "email", format: "email",
}), }),
password: s.string({ password: s.string({
minLength: this.config.minLength, minLength: 8, // @todo: this should be configurable
}), }),
}); });
} }
@@ -66,21 +65,12 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
return await bcryptCompare(compare, actual); return await bcryptCompare(compare, actual);
} }
return actual === compare; return false;
} }
verify(password: string) { verify(password: string) {
return async (user: User) => { return async (user: User) => {
if (!user || !user.strategy_value) { const compare = await this.compare(user?.strategy_value!, password);
throw new InvalidCredentialsException();
}
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
$console.debug("PasswordStrategy: Invalid password", password);
throw new InvalidCredentialsException();
}
const compare = await this.compare(user.strategy_value, password);
if (compare !== true) { if (compare !== true) {
throw new InvalidCredentialsException(); throw new InvalidCredentialsException();
} }
+6 -149
View File
@@ -4,9 +4,7 @@ import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd"; import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
import dotenv from "dotenv"; import dotenv from "dotenv";
import * as errore from "errore";
import c from "picocolors"; import c from "picocolors";
import { execFileSync } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { import {
PLATFORMS, PLATFORMS,
@@ -20,26 +18,6 @@ import { createRuntimeApp, makeConfig } from "bknd/adapter";
import { colorizeConsole, isBun } from "bknd/utils"; import { colorizeConsole, isBun } from "bknd/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
class ConfigLoadError extends errore.createTaggedError({
name: "ConfigLoadError",
message: "Failed to load config: $reason",
}) {}
class NeedsBunError extends errore.createTaggedError({
name: "NeedsBunError",
message: "Config requires Bun runtime",
}) {}
class NeedsTypeStrippingError extends errore.createTaggedError({
name: "NeedsTypeStrippingError",
message: "Node $version needs --experimental-strip-types for .ts config",
}) {}
class ReexecFailedError extends errore.createTaggedError({
name: "ReexecFailedError",
message: "Re-exec failed: $reason",
}) {}
const env_files = [".env", ".dev.vars"]; const env_files = [".env", ".dev.vars"];
dotenv.config({ dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)), path: env_files.map((file) => path.resolve(process.cwd(), file)),
@@ -77,106 +55,6 @@ if (!registries.media.has(local)) {
registries.media.register(local, StorageLocalAdapter); registries.media.register(local, StorageLocalAdapter);
} }
function needsTypeStripping(configFilePath: string): boolean {
if (!/\.[mc]?ts$/.test(configFilePath)) return false;
const [major, minor] = process.versions.node.split(".").map(Number);
// Node v22.06 introduced experimental TypeScript support via strip types.
return major === 22 && minor! < 18 && minor! > 5;
}
function reexecWithTypeStripping(): ReexecFailedError | never {
if (process.env.__BKND_REEXEC) {
return new ReexecFailedError({
reason: "TS config still failed after re-exec with --experimental-strip-types",
});
}
const cliPath = path.resolve(process.argv[1]!);
const args = ["--experimental-strip-types", cliPath, ...process.argv.slice(2)];
console.info(
c.yellow("Node <22.18 detected, re-executing with --experimental-strip-types"),
);
const result = errore.try({
try: () =>
execFileSync(process.execPath, args, {
stdio: "inherit",
env: { ...process.env, __BKND_REEXEC: "1" },
}),
catch: (e) =>
new ReexecFailedError({
reason: "Failed to re-exec with --experimental-strip-types",
cause: e,
}),
});
if (result instanceof Error) return result;
process.exit(0);
}
async function loadConfigFile(
configFilePath: string,
): Promise<ConfigLoadError | NeedsBunError | NeedsTypeStrippingError | CliBkndConfig> {
if (needsTypeStripping(configFilePath) && !process.execArgv.includes("--experimental-strip-types")) {
return new NeedsTypeStrippingError({
version: process.versions.node,
});
}
const result = await errore.tryAsync({
try: () => import(configFilePath).then((m) => m.default as CliBkndConfig),
catch: (e) => {
const needsBun =
(e instanceof ReferenceError && /\bBun\b.*not defined/.test(e.message)) ||
(e instanceof Error && "code" in e && e.code === "ERR_UNSUPPORTED_ESM_URL_SCHEME" && /bun:/.test(e.message));
if (needsBun) return new NeedsBunError();
return new ConfigLoadError({
reason: e instanceof Error ? e.message : String(e),
cause: e,
});
},
});
return result;
}
function reexecUnderBun(): ReexecFailedError | never {
if (process.env.__BKND_REEXEC) {
return new ReexecFailedError({
reason: "Config requires Bun but still failed under Bun runtime",
});
}
const bunPath = process.env.BUN_INSTALL
? path.join(process.env.BUN_INSTALL, "bin", "bun")
: "bun";
const cliPath = path.resolve(process.argv[1]!);
const args = [cliPath, ...process.argv.slice(2)];
console.info(
c.yellow("Config requires Bun runtime, re-executing:"),
c.cyan(`bun ${args.join(" ")}`),
);
const result = errore.try({
try: () =>
execFileSync(bunPath, args, {
stdio: "inherit",
env: { ...process.env, __BKND_REEXEC: "1" },
}),
catch: (e) =>
new ReexecFailedError({
reason: "Could not re-exec under Bun. Install Bun (https://bun.sh) or use bknd/adapter/node in your config.",
cause: e,
}),
});
if (result instanceof Error) return result;
process.exit(0);
}
type MakeAppConfig = { type MakeAppConfig = {
connection?: CreateAppConfig["connection"]; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
@@ -221,33 +99,12 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// check configuration file to be present // check configuration file to be present
} else if (configFilePath) { } else if (configFilePath) {
console.info("Using config from", c.cyan(configFilePath)); console.info("Using config from", c.cyan(configFilePath));
const configResult = await loadConfigFile(configFilePath); try {
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
if (configResult instanceof Error) { app = await makeConfigApp(config, options.server);
errore.matchError(configResult, { } catch (e) {
NeedsTypeStrippingError: () => { console.error("Failed to load config:", e);
const reexecResult = reexecWithTypeStripping(); process.exit(1);
// only reached on failure — success calls process.exit(0)
console.error(c.red(reexecResult.message));
process.exit(1);
},
NeedsBunError: () => {
const reexecResult = reexecUnderBun();
// only reached on failure — success calls process.exit(0)
console.error(c.red(reexecResult.message));
process.exit(1);
},
ConfigLoadError: (e) => {
console.error(c.red("Failed to load config:"), e.reason);
process.exit(1);
},
Error: (e) => {
console.error(c.red("Failed to load config:"), e.message);
process.exit(1);
},
});
} else {
app = await makeConfigApp(configResult, options.server);
} }
// try to use an in-memory connection // try to use an in-memory connection
+1 -1
View File
@@ -8,7 +8,7 @@ export function isDebug(): boolean {
try { try {
// @ts-expect-error - this is a global variable in dev // @ts-expect-error - this is a global variable in dev
return is_toggled(__isDev); return is_toggled(__isDev);
} catch (_e) { } catch (e) {
return false; return false;
} }
} }
-1
View File
@@ -32,7 +32,6 @@ export function getFlashMessage(
): { type: FlashMessageType; message: string } | undefined { ): { type: FlashMessageType; message: string } | undefined {
const flash = getCookieValue(flash_key); const flash = getCookieValue(flash_key);
if (flash && clear) { if (flash && clear) {
// biome-ignore lint/suspicious/noDocumentCookie: .
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
} }
return flash ? JSON.parse(flash) : undefined; return flash ? JSON.parse(flash) : undefined;
+2 -2
View File
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
export function omitKeys<T extends object, K extends keyof T>( export function omitKeys<T extends object, K extends keyof T>(
obj: T, obj: T,
keys_: readonly K[] | K[] | string[], keys_: readonly K[],
): Omit<T, Extract<K, keyof T>> { ): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_ as readonly K[]); const keys = new Set(keys_);
const result = {} as Omit<T, Extract<K, keyof T>>; const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) { if (!keys.has(key as K)) {
-19
View File
@@ -1,4 +1,3 @@
import type { MaybePromise } from "core/types";
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
/** /**
@@ -94,21 +93,3 @@ export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[
return true; return true;
} }
} }
export async function $waitUntil(
message: string,
condition: () => MaybePromise<boolean>,
delay = 100,
maxAttempts = 10,
) {
let attempts = 0;
while (attempts < maxAttempts) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
}
+1 -10
View File
@@ -96,9 +96,6 @@ export class DataController extends Controller {
// read entity schema // read entity schema
hono.get( hono.get(
"/schema.json", "/schema.json",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -127,9 +124,6 @@ export class DataController extends Controller {
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -167,7 +161,7 @@ export class DataController extends Controller {
hono.get( hono.get(
"/types", "/types",
permission(SystemPermissions.schemaRead, { permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }), context: (c) => ({ module: "data" }),
}), }),
describeRoute({ describeRoute({
summary: "Retrieve data typescript definitions", summary: "Retrieve data typescript definitions",
@@ -188,9 +182,6 @@ export class DataController extends Controller {
*/ */
hono.get( hono.get(
"/info/:entity", "/info/:entity",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
+3
View File
@@ -6,12 +6,15 @@ import {
type CompiledQuery, type CompiledQuery,
type DatabaseIntrospector, type DatabaseIntrospector,
type Dialect, type Dialect,
type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction, type OnModifyForeignAction,
type QueryResult, type QueryResult,
type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
type Simplify,
sql, sql,
} from "kysely"; } from "kysely";
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
@@ -14,31 +14,27 @@ export function connectionTestSuite(
{ {
makeConnection, makeConnection,
rawDialectDetails, rawDialectDetails,
disableConsoleLog: _disableConsoleLog = true,
}: { }: {
makeConnection: () => MaybePromise<{ makeConnection: () => MaybePromise<{
connection: Connection; connection: Connection;
dispose: () => MaybePromise<void>; dispose: () => MaybePromise<void>;
}>; }>;
rawDialectDetails: string[]; rawDialectDetails: string[];
disableConsoleLog?: boolean;
}, },
) { ) {
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
if (_disableConsoleLog) { beforeAll(() => disableConsoleLog());
beforeAll(() => disableConsoleLog()); afterAll(() => enableConsoleLog());
afterAll(() => enableConsoleLog());
}
let ctx: Awaited<ReturnType<typeof makeConnection>>; describe("base", () => {
beforeEach(async () => { let ctx: Awaited<ReturnType<typeof makeConnection>>;
ctx = await makeConnection(); beforeEach(async () => {
}); ctx = await makeConnection();
afterEach(async () => { });
await ctx.dispose(); afterEach(async () => {
}); await ctx.dispose();
});
describe("base", async () => {
test("pings", async () => { test("pings", async () => {
const res = await ctx.connection.ping(); const res = await ctx.connection.ping();
expect(res).toBe(true); expect(res).toBe(true);
@@ -102,54 +98,52 @@ export function connectionTestSuite(
}); });
describe("schema", async () => { describe("schema", async () => {
const makeSchema = async () => { const { connection, dispose } = await makeConnection();
const fields = [ afterAll(async () => {
{ await dispose();
type: "integer", });
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = ctx.connection.kysely.schema.createTable("test"); const fields = [
for (const field of fields) { {
// @ts-expect-error type: "integer",
b = b.addColumn(...ctx.connection.getFieldSchema(field)); name: "id",
} primary: true,
await b.execute(); },
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
// add index let b = connection.kysely.schema.createTable("test");
await ctx.connection.kysely.schema for (const field of fields) {
.createIndex("test_index") // @ts-expect-error
.on("test") b = b.addColumn(...connection.getFieldSchema(field));
.columns(["id"]) }
.execute(); await b.execute();
};
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
test("executes query", async () => { test("executes query", async () => {
await makeSchema(); await connection.kysely
await ctx.connection.kysely
.insertInto("test") .insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) }) .values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute(); .execute();
const expected = { id: 1, text: "test", json: { a: 1 } }; const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = ctx.connection.kysely.selectFrom("test").selectAll(); const qb = connection.kysely.selectFrom("test").selectAll();
const res = await ctx.connection.executeQuery(qb); const res = await connection.executeQuery(qb);
expect(res.rows).toEqual([expected]); expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true); expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
{ {
const res = await ctx.connection.executeQueries(qb, qb); const res = await connection.executeQueries(qb, qb);
expect(res.length).toBe(2); expect(res.length).toBe(2);
res.map((r) => { res.map((r) => {
expect(r.rows).toEqual([expected]); expect(r.rows).toEqual([expected]);
@@ -161,21 +155,15 @@ export function connectionTestSuite(
}); });
test("introspects", async () => { test("introspects", async () => {
await makeSchema(); const tables = await connection.getIntrospector().getTables({
const tables = await ctx.connection.getIntrospector().getTables({
withInternalKyselyTables: false, withInternalKyselyTables: false,
}); });
const clean = tables.map((t) => ({ const clean = tables.map((t) => ({
...t, ...t,
columns: t.columns columns: t.columns.map((c) => ({
.map((c) => ({ ...c,
...c, dataType: undefined,
// 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)),
})); }));
expect(clean).toEqual([ expect(clean).toEqual([
@@ -188,16 +176,7 @@ export function connectionTestSuite(
dataType: undefined, dataType: undefined,
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: undefined,
comment: undefined,
},
{
name: "json",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined,
}, },
{ {
name: "text", name: "text",
@@ -205,26 +184,32 @@ export function connectionTestSuite(
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined,
}, },
],
},
]);
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{ {
name: "id", name: "json",
order: 0, 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 () => { 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 { SqliteIntrospector } from "./SqliteIntrospector";
import type { Field } from "data/fields/Field"; import type { Field } from "data/fields/Field";
// @todo: add pragmas
export type SqliteConnectionConfig< export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>, CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = { > = {
excludeTables?: string[]; excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[]; additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>; customFn?: Partial<DbFunctions>;
} & ( };
| {
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
}
| {
kysely: Kysely<any>;
}
);
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> { export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite"; override name = "sqlite";
constructor(config: SqliteConnectionConfig) { constructor(config: SqliteConnectionConfig) {
const { excludeTables, additionalPlugins } = config; const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])]; const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
let kysely: Kysely<any>; const kysely = new Kysely({
if ("dialect" in config) { dialect: customIntrospector(dialect, SqliteIntrospector, {
kysely = new Kysely({ excludeTables,
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...(config.dialectArgs ?? [])),
plugins, plugins,
}); }).create(...dialectArgs),
} else if ("kysely" in config) { plugins,
kysely = config.kysely; });
} else {
throw new Error("Either dialect or kysely must be provided");
}
super( super(
kysely, kysely,
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
dataType: col.type, dataType: col.type,
isNullable: !col.notnull, isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol, isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null, hasDefaultValue: col.dflt_value != null,
comment: undefined, comment: undefined,
}; };
}) ?? [], }) ?? [],
@@ -1,15 +0,0 @@
import { describe } from "bun:test";
import { SQLocalConnection } from "./SQLocalConnection";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { SQLocalKysely } from "sqlocal/kysely";
describe("SQLocalConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => ({
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -1,50 +0,0 @@
import { Kysely, ParseJSONResultsPlugin } from "kysely";
import { SqliteConnection } from "../SqliteConnection";
import { SqliteIntrospector } from "../SqliteIntrospector";
import type { DB } from "bknd";
import type { SQLocalKysely } from "sqlocal/kysely";
const plugins = [new ParseJSONResultsPlugin()];
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
private connected: boolean = false;
constructor(client: SQLocalKysely) {
// @ts-expect-error - config is protected
client.config.onConnect = () => {
// we need to listen for the connection, it will be awaited in init()
this.connected = true;
};
super({
kysely: new Kysely<any>({
dialect: {
...client.dialect,
createIntrospector: (db: Kysely<DB>) => {
return new SqliteIntrospector(db as any, {
plugins,
});
},
},
plugins,
}) as any,
});
this.client = client;
}
override async init() {
if (this.initialized) return;
let tries = 0;
while (!this.connected && tries < 100) {
tries++;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!this.connected) {
throw new Error("Failed to connect to SQLite database");
}
this.initialized = true;
}
}
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
return new SQLocalConnection(instance);
}
+3 -20
View File
@@ -103,7 +103,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.with = options.with; validated.with = options.with;
} }
// add explicit joins. Implicit joins are added in `where` builder
if (options.join && options.join.length > 0) { if (options.join && options.join.length > 0) {
for (const entry of options.join) { for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry); const related = this.em.relationOf(entity.name, entry);
@@ -128,28 +127,12 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => { const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
if (field.includes(".")) { if (field.includes(".")) {
const [alias, prop] = field.split(".") as [string, string]; const [alias, prop] = field.split(".") as [string, string];
// check aliases first (added joins) if (!aliases.includes(alias)) {
if (aliases.includes(alias)) {
this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
}
// check if alias (entity) exists
if (!this.em.hasEntity(alias)) {
return true; return true;
} }
// check related fields for auto join
const related = this.em.relationOf(entity.name, alias);
if (related) {
const other = related.other(entity);
if (other.entity.getField(prop)) {
// if related field is found, add join to validated options
validated.join?.push(alias);
this.checkIndex(alias, prop, "where");
return false;
}
}
return true; this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
} }
this.checkIndex(entity.name, field, "where"); this.checkIndex(entity.name, field, "where");
+1 -1
View File
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
super(Object.values(__entities), new DummyConnection(), relations, indices); super(Object.values(__entities), new DummyConnection(), relations, indices);
} }
withConnection(connection: Connection): EntityManager<Schemas<Entities>> { withConnection(connection: Connection): EntityManager<Schema<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices); return new EntityManager(this.entities, connection, this.relations.all, this.indices);
} }
} }
+1 -5
View File
@@ -1,9 +1,8 @@
import { test, describe, expect, beforeAll, afterAll } from "bun:test"; import { test, describe, expect } from "bun:test";
import * as q from "./query"; import * as q from "./query";
import { parse as $parse, type ParseOptions } from "bknd/utils"; import { parse as $parse, type ParseOptions } from "bknd/utils";
import type { PrimaryFieldType } from "modules"; import type { PrimaryFieldType } from "modules";
import type { Generated } from "kysely"; import type { Generated } from "kysely";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const parse = (v: unknown, o: ParseOptions = {}) => const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, { $parse(q.repoQuery, v, {
@@ -16,9 +15,6 @@ const decode = (input: any, output: any) => {
expect(parse(input)).toEqual(output); expect(parse(input)).toEqual(output);
}; };
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("server/query", () => { describe("server/query", () => {
test("limit & offset", () => { test("limit & offset", () => {
//expect(() => parse({ limit: false })).toThrow(); //expect(() => parse({ limit: false })).toThrow();
-25
View File
@@ -132,8 +132,6 @@ export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager"; export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager"; export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities"; export type * from "data/entities";
// data connection
export { export {
BaseIntrospector, BaseIntrospector,
Connection, Connection,
@@ -146,32 +144,9 @@ export {
type ConnQuery, type ConnQuery,
type ConnQueryResults, type ConnQueryResults,
} from "data/connection"; } from "data/connection";
// data sqlite
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection"; export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector"; export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection"; export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
// data sqlocal
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
// data postgres
export {
pg,
PgPostgresConnection,
} from "data/connection/postgres/PgPostgresConnection";
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
export {
postgresJs,
PostgresJsConnection,
} from "data/connection/postgres/PostgresJsConnection";
export {
createCustomPostgresConnection,
type CustomPostgresConnection,
} from "data/connection/postgres/custom";
// data prototype
export { export {
text, text,
number, number,
+4 -17
View File
@@ -71,12 +71,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
} }
protected uploadFile<T extends FileUploadedEventData>( protected uploadFile<T extends FileUploadedEventData>(
body: BodyInit, body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
opts?: { opts?: {
filename?: string; filename?: string;
path?: TInput; path?: TInput;
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
query?: Record<string, any>;
}, },
): FetchPromise<ResponseObject<T>> { ): FetchPromise<ResponseObject<T>> {
const headers = { const headers = {
@@ -103,22 +102,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
headers, headers,
}; };
if (opts?.path) { if (opts?.path) {
return this.request<T>(opts.path, opts?.query, { return this.post(opts.path, body, init);
...init,
body,
method: "POST",
});
} }
if (!name || name.length === 0) { if (!name || name.length === 0) {
throw new Error("Invalid filename"); throw new Error("Invalid filename");
} }
return this.request<T>(opts?.path ?? ["upload", name], opts?.query, { return this.post<T>(opts?.path ?? ["upload", name], body, init);
...init,
body,
method: "POST",
});
} }
async upload<T extends FileUploadedEventData>( async upload<T extends FileUploadedEventData>(
@@ -128,7 +119,6 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
path?: TInput; path?: TInput;
fetcher?: ApiFetcher; fetcher?: ApiFetcher;
query?: Record<string, any>;
} = {}, } = {},
) { ) {
if (item instanceof Request || typeof item === "string") { if (item instanceof Request || typeof item === "string") {
@@ -154,7 +144,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}); });
} }
return this.uploadFile<T>(item as BodyInit, opts); return this.uploadFile<T>(item, opts);
} }
async uploadToEntity( async uploadToEntity(
@@ -165,14 +155,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
opts?: { opts?: {
_init?: Omit<RequestInit, "body">; _init?: Omit<RequestInit, "body">;
fetcher?: typeof fetch; fetcher?: typeof fetch;
overwrite?: boolean;
}, },
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> { ): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
const query = opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : undefined;
return this.upload(item, { return this.upload(item, {
...opts, ...opts,
path: ["entity", entity, id, field], path: ["entity", entity, id, field],
query,
}); });
} }
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
export async function adapterTestSuite( export async function adapterTestSuite(
testRunner: TestRunner, testRunner: TestRunner,
_adapter: StorageAdapter | (() => StorageAdapter), adapter: StorageAdapter,
file: File | BunFile, file: File | BunFile,
opts?: { opts?: {
retries?: number; retries?: number;
@@ -25,12 +25,7 @@ export async function adapterTestSuite(
const _filename = randomString(10); const _filename = randomString(10);
const filename = `${_filename}.png`; const filename = `${_filename}.png`;
const getAdapter = (
typeof _adapter === "function" ? _adapter : () => _adapter
) as () => StorageAdapter;
await test("puts an object", async () => { await test("puts an object", async () => {
const adapter = getAdapter();
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
const result = await adapter.putObject(filename, file as unknown as File); const result = await adapter.putObject(filename, file as unknown as File);
expect(result).toBeDefined(); expect(result).toBeDefined();
@@ -43,7 +38,6 @@ export async function adapterTestSuite(
}); });
await test("lists objects", async () => { await test("lists objects", async () => {
const adapter = getAdapter();
const length = await retry( const length = await retry(
() => adapter.listObjects().then((res) => res.length), () => adapter.listObjects().then((res) => res.length),
(length) => length > objects, (length) => length > objects,
@@ -55,12 +49,10 @@ export async function adapterTestSuite(
}); });
await test("file exists", async () => { await test("file exists", async () => {
const adapter = getAdapter();
expect(await adapter.objectExists(filename)).toBe(true); expect(await adapter.objectExists(filename)).toBe(true);
}); });
await test("gets an object", async () => { await test("gets an object", async () => {
const adapter = getAdapter();
const res = await adapter.getObject(filename, new Headers()); const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.headers.get("Accept-Ranges")).toBe("bytes"); expect(res.headers.get("Accept-Ranges")).toBe("bytes");
@@ -70,7 +62,6 @@ export async function adapterTestSuite(
if (options.testRange) { if (options.testRange) {
await test("handles range request - partial content", async () => { await test("handles range request - partial content", async () => {
const headers = new Headers({ Range: "bytes=0-99" }); const headers = new Headers({ Range: "bytes=0-99" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -79,7 +70,6 @@ export async function adapterTestSuite(
await test("handles range request - suffix range", async () => { await test("handles range request - suffix range", async () => {
const headers = new Headers({ Range: "bytes=-100" }); const headers = new Headers({ Range: "bytes=-100" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -87,7 +77,6 @@ export async function adapterTestSuite(
await test("handles invalid range request", async () => { await test("handles invalid range request", async () => {
const headers = new Headers({ Range: "bytes=invalid" }); const headers = new Headers({ Range: "bytes=invalid" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(416); // Range Not Satisfiable expect(res.status).toBe(416); // Range Not Satisfiable
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -95,7 +84,6 @@ export async function adapterTestSuite(
} }
await test("gets object meta", async () => { await test("gets object meta", async () => {
const adapter = getAdapter();
expect(await adapter.getObjectMeta(filename)).toEqual({ expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png type: file.type, // image/png
size: file.size, size: file.size,
@@ -103,7 +91,6 @@ export async function adapterTestSuite(
}); });
await test("deletes an object", async () => { await test("deletes an object", async () => {
const adapter = getAdapter();
expect(await adapter.deleteObject(filename)).toBeUndefined(); expect(await adapter.deleteObject(filename)).toBeUndefined();
if (opts?.skipExistsAfterDelete !== true) { if (opts?.skipExistsAfterDelete !== true) {
+1 -1
View File
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity; verbosity?: Verbosity;
}; };
const debug_modules = env("modules_debug", false); const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {} abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent< export class ModuleManagerConfigUpdateEvent<
-2
View File
@@ -33,5 +33,3 @@ export const schemaRead = new Permission(
); );
export const build = new Permission("system.build"); export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp"); export const mcp = new Permission("system.mcp");
export const info = new Permission("system.info");
export const openapi = new Permission("system.openapi");
+4 -12
View File
@@ -1,3 +1,5 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App"; import type { App } from "App";
import { import {
datetimeStringLocal, datetimeStringLocal,
@@ -357,7 +359,7 @@ export class SystemController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); const hono = this.create().use(auth());
this.registerConfigController(hono); this.registerConfigController(hono);
@@ -432,9 +434,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/permissions", "/permissions",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "auth" }),
}),
describeRoute({ describeRoute({
summary: "Get the permissions", summary: "Get the permissions",
tags: ["system"], tags: ["system"],
@@ -447,7 +446,6 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/build", "/build",
permission(SystemPermissions.build, {}),
describeRoute({ describeRoute({
summary: "Build the app", summary: "Build the app",
tags: ["system"], tags: ["system"],
@@ -478,7 +476,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/info", "/info",
permission(SystemPermissions.info, {}),
mcpTool("system_info"), mcpTool("system_info"),
describeRoute({ describeRoute({
summary: "Get the server info", summary: "Get the server info",
@@ -512,7 +509,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/openapi.json", "/openapi.json",
permission(SystemPermissions.openapi, {}),
openAPISpecs(this.ctx.server, { openAPISpecs(this.ctx.server, {
info: { info: {
title: "bknd API", title: "bknd API",
@@ -520,11 +516,7 @@ export class SystemController extends Controller {
}, },
}), }),
); );
hono.get( hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
"/swagger",
permission(SystemPermissions.openapi, {}),
swaggerUI({ url: "/api/system/openapi.json" }),
);
return hono; return hono;
} }
+3 -4
View File
@@ -126,7 +126,7 @@ export function emailOTP({
...entityConfig, ...entityConfig,
}, },
"generated", "generated",
) as any, ),
}, },
({ index }, schema) => { ({ index }, schema) => {
const otp = schema[entityName]!; const otp = schema[entityName]!;
@@ -153,7 +153,7 @@ export function emailOTP({
"/login", "/login",
jsc( jsc(
"json", "json",
s.strictObject({ s.object({
email: s.string({ format: "email" }), email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(), code: s.string({ minLength: 1 }).optional(),
}), }),
@@ -213,7 +213,7 @@ export function emailOTP({
), ),
jsc("query", s.object({ redirect: s.string().optional() })), jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => { async (c) => {
const { email, code, ...rest } = c.req.valid("json"); const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query"); const { redirect } = c.req.valid("query");
// throw if user exists // throw if user exists
@@ -232,7 +232,6 @@ export function emailOTP({
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const user = await app.createUser({ const user = await app.createUser({
...rest,
email, email,
password: randomString(32, true), password: randomString(32, true),
}); });
+858
View File
@@ -0,0 +1,858 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { sort } from "./sort.plugin";
import { em, entity, text, number } from "bknd";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("sort plugin", () => {
test("should add sort field to configured entities", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const taskEntity = app.em.entity("tasks");
expect(taskEntity).toBeDefined();
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(taskEntity?.field("position")?.type).toBe("number");
});
test("should auto-assign sort values on insert (starting from 0)", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert first item
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
expect(task1.position).toBe(0);
// insert second item
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task2.position).toBe(1);
// insert third item
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
expect(task3.position).toBe(2);
});
test("should preserve manually set sort values on insert", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert with explicit position
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
expect(task.position).toBe(10);
});
test("should reorder items via API endpoint", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// move task3 (position 2) to position 0
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task3.id, position: 0 }),
});
expect(res.status).toBe(200);
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask3.position).toBe(0); // moved to position 0
expect(updatedTask1.position).toBe(1); // shifted down
expect(updatedTask2.position).toBe(2); // shifted down
});
test("should automatically reorder when updating sort field directly", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task4 (position 3) to position 1 by updating directly
await mutator.updateOne(task4.id, { position: 1 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(0); // unchanged
expect(updatedTask2.position).toBe(2); // shifted down
expect(updatedTask3.position).toBe(3); // shifted down
expect(updatedTask4.position).toBe(1); // moved to position 1
});
test("should automatically reorder when moving items up", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 (position 0) to position 2 by updating directly
await mutator.updateOne(task1.id, { position: 2 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(2); // moved to position 2
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(3); // unchanged
});
test("should support scoped sorting", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// positions should be scoped per project
expect(p1t1.position).toBe(0);
expect(p1t2.position).toBe(1);
expect(p2t1.position).toBe(0); // resets for new scope
expect(p2t2.position).toBe(1);
});
test("should reorder only within scope", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
const { data: p1t3 } = await mutator.insertOne({
title: "P1 Task 3",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// move p1t3 to position 0 (should only affect project 1)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: p1t3.id, position: 0 }),
});
// verify project 1 tasks
const repo = app.em.repo("tasks");
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
expect(updatedP1t3.position).toBe(0); // moved to position 0
expect(updatedP1t1.position).toBe(1); // shifted
expect(updatedP1t2.position).toBe(2); // shifted
// verify project 2 tasks are unchanged
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
expect(updatedP2t1.position).toBe(0); // unchanged
expect(updatedP2t2.position).toBe(1); // unchanged
});
test("should recalculate all positions", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with irregular positions
await mutator.insertOne({ title: "Task 1", position: 5 });
await mutator.insertOne({ title: "Task 2", position: 10 });
await mutator.insertOne({ title: "Task 3", position: 15 });
await mutator.insertOne({ title: "Task 4", position: 100 });
// recalculate
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
});
expect(res.status).toBe(200);
// verify positions are now 0, 1, 2, 3
const { data: tasks } = await app.em.repo("tasks").findMany({
orderBy: [{ position: "asc" }],
});
expect(tasks.length).toBe(4);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
expect(tasks[2].position).toBe(2);
expect(tasks[3].position).toBe(3);
});
test("should recalculate positions within scope only", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1 with irregular positions
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
// create tasks in project 2 with irregular positions
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
// recalculate only project 1
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ scope: 1 }),
});
expect(res.status).toBe(200);
// verify project 1 tasks are recalculated
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 1 },
orderBy: [{ position: "asc" }],
});
expect(p1Tasks.length).toBe(2);
expect(p1Tasks[0].position).toBe(0);
expect(p1Tasks[1].position).toBe(1);
// verify project 2 tasks are unchanged
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 2 },
orderBy: [{ position: "asc" }],
});
expect(p2Tasks.length).toBe(2);
expect(p2Tasks[0].position).toBe(10); // unchanged
expect(p2Tasks[1].position).toBe(20); // unchanged
});
test("should handle moving items to the end", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 to the end (position 3)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task1.id, position: 3 }),
});
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(3); // moved to end
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(2); // shifted up
});
test("should return 400 for invalid item id", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: 999999, position: 0 }),
});
expect(res.status).toBe(400);
});
test("should handle multiple entities with different configurations", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
categories: entity("categories", {
name: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
categories: {
field: "order",
},
},
}),
],
},
});
await app.build();
// verify both entities have their sort fields
const taskEntity = app.em.entity("tasks");
const categoryEntity = app.em.entity("categories");
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
// create items in both entities
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
expect(task.position).toBe(0);
expect(category.order).toBe(0);
// verify both endpoints exist
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const catRes = await app.server.request("/api/sort/categories/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(taskRes.status).toBe(200);
expect(catRes.status).toBe(200);
});
test("should not trigger reorder when updating other fields", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// update title only (should not trigger reordering)
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
// verify positions are unchanged
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask1.position).toBe(0);
expect(updatedTask2.position).toBe(1);
expect(updatedTask2.title).toBe("Task 2 Updated");
expect(updatedTask3.position).toBe(2);
});
test("should handle null sort values", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create task with null position (bypass listener by using kysely directly)
await app.connection.kysely
.insertInto("tasks")
.values({ title: "Task with null", position: null })
.execute();
// create normal task
await mutator.insertOne({ title: "Task 2" });
// update the null task to have a position
const nullTask = await app.connection.kysely
.selectFrom("tasks")
.selectAll()
.where("title", "=", "Task with null")
.executeTakeFirst();
await mutator.updateOne(nullTask!.id, { position: 0 });
// verify both tasks have proper positions
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
});
test("should not create duplicates when moving to an occupied position", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create two tasks at positions 0 and 1
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task1.position).toBe(0);
expect(task2.position).toBe(1);
// move task2 (at position 1) to position 0
await mutator.updateOne(task2.id, { position: 0 });
// verify no duplicates
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].id).toBe(task2.id);
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(task1.id);
expect(tasks[1].position).toBe(1);
// verify no tasks have the same position
const positions = tasks.map((t) => t.position);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(positions.length);
});
test("should preserve order when recalculating", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with specific positions
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
// recalculate
await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
// verify order is preserved (B, A, C based on original positions)
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
expect(tasks[1].position).toBe(1);
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
expect(tasks[2].position).toBe(2);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
import { Hono } from "hono";
import { sql } from "kysely";
const DEFAULT_BATCH_SIZE = 1000;
export type SortPluginOptions = {
/**
* The base path for the API endpoints.
* @default "/api/sort"
*/
apiBasePath?: string;
/**
* Configuration for entities that should have sorting enabled.
* Key is the entity name, value is the configuration.
*/
entities: Record<
string,
{
/**
* The name of the sort property (must be a number field).
*/
field: string;
/**
* Optional scope field name. If provided, sorting will only happen within the same scope.
* For example, if scope is "category_id", items will only be sorted within items
* that have the same category_id value.
*/
scope?: string;
}
>;
/**
* The batch size for recalculating sort order.
* @default 1000
*/
recalculateBatchSize?: number;
};
class SortError extends Exception {
override name = "SortError";
override code = HttpStatus.BAD_REQUEST;
}
export function sort({
apiBasePath = "/api/sort",
entities,
recalculateBatchSize = DEFAULT_BATCH_SIZE,
}: SortPluginOptions): AppPlugin {
return (app: App) => {
return {
name: "sort",
schema: () => {
return em(
Object.fromEntries(
Object.entries(entities).map(([entityName, config]) => [
entityName,
entity(entityName, {
[config.field]: number({ default_value: 0 }),
}),
]),
),
({ index }, schema) => {
for (const [entityName, config] of Object.entries(entities)) {
const indexed = app.em.getIndexedFields(entityName);
if (!indexed.some((f) => f.name === config.field)) {
index(schema[entityName]!).on([config.field]);
}
}
},
);
},
onBuilt: async () => {
invariant(
entities && Object.keys(entities).length > 0,
"At least one entity must be configured",
);
// validate entities exist and have the configured fields
for (const [entityName, config] of Object.entries(entities)) {
const entity = app.em.entity(entityName);
invariant(entity, `Entity "${entityName}" not found in schema`);
const sortFieldSchema = entity.field(config.field)!;
invariant(
sortFieldSchema,
`Sort field "${config.field}" not found in entity "${entityName}"`,
);
invariant(
sortFieldSchema.type === "number",
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
);
if (config.scope) {
const scopeFieldSchema = entity.field(config.scope);
invariant(
scopeFieldSchema,
`Scope field "${config.scope}" not found in entity "${entityName}"`,
);
}
}
const hono = new Hono();
// register recalculate endpoints for each entity
for (const [entityName, config] of Object.entries(entities)) {
hono.post(
`/${entityName}/recalculate`,
jsc(
"json",
s
.object({
scope: s.any().optional(),
})
.optional(),
),
async (c) => {
const body = c.req.valid("json");
const scope = body?.scope;
await recalculateSortOrder(
app,
entityName,
config,
recalculateBatchSize,
scope,
);
return c.json({ success: true, message: "Sort order recalculated" });
},
);
hono.post(
`/${entityName}/reorder`,
jsc(
"json",
s.object({
id: s.any(),
position: s.number(),
}),
),
async (c) => {
const { id, position } = c.req.valid("json");
await reorderItem(app, entityName, config, id, position);
return c.json({ success: true, message: "Item reordered" });
},
);
}
app.server.route(apiBasePath, hono);
// register listeners for automatic reordering
registerListeners(app, entities);
},
};
};
}
async function reorderItem(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
id: any,
newPosition: number,
) {
const { field: sortField, scope: scopeField } = config;
const em = app.em.fork();
const kysely = em.connection.kysely;
// get the item
const { data: item } = await em.repo(entityName).findOne({ id });
if (!item) {
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
}
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// update the item with new position (using forked em, so no listeners are triggered)
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
// shift other items using kysely
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
} else if (oldPosition === undefined || oldPosition === null) {
// new item, shift everything at or after this position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
async function recalculateSortOrder(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
batchSize: number,
scope?: any,
) {
const { field: sortField, scope: scopeField } = config;
const db = app.connection.kysely;
const { count } = (await db
.selectFrom(entityName)
.select((eb) => eb.fn.count<number>("id").as("count"))
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.$castTo<{ count: number }>()
.executeTakeFirst()) ?? { count: 0 };
const batches = Math.ceil(count / batchSize);
for (let i = 0; i < batches; i++) {
// get all items in scope, ordered by current sort value
const items = await db
.selectFrom(entityName)
.select(["id", sortField])
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.orderBy(sortField, "asc")
.limit(batchSize)
.offset(i * batchSize)
.execute();
const newQbs = items.map((item, index) =>
db
.updateTable(entityName)
.set({ [sortField]: index })
.where("id", "=", item.id),
);
await app.connection.executeQueries(...newQbs);
$console.log(
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
);
}
}
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
const kysely = app.connection.kysely;
// handle insert events
app.emgr.onEvent(
DatabaseEvents.MutatorInsertBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
const scopeValue = scopeField ? data[scopeField] : undefined;
// if no position provided, set to max + 1
if (data[sortField] === undefined || data[sortField] === null) {
const query = kysely
.selectFrom(entityName)
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
// add scope filter if needed
.$if(Boolean(scopeField && scopeValue), (eb) =>
eb.where(scopeField as any, "=", scopeValue as any),
);
const result = await query.executeTakeFirst();
const max = result?.max ?? -1;
return {
...data,
[sortField]: max + 1,
};
}
// if position is provided, shift other items at or after that position
const newPosition = data[sortField] as number;
let shiftQuery = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition);
if (scopeField && scopeValue !== undefined) {
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
}
await shiftQuery.execute();
return data;
},
{
mode: "sync",
id: "bknd-sort-insert",
},
);
// handle update events
app.emgr.onEvent(
DatabaseEvents.MutatorUpdateBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
// only handle if sort field is being updated
if (!(sortField in data)) return e.params.data;
const newPosition = data[sortField] as number;
const id = e.params.entityId;
// get the current item to know its old position and scope
const item = await kysely
.selectFrom(entityName)
.selectAll()
.where("id" as any, "=", id)
.executeTakeFirst();
if (!item) return data;
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// if oldPosition is null or undefined, treat as inserting at newPosition
if (oldPosition === null || oldPosition === undefined) {
// shift items at or after the new position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
return data;
}
// shift other items using kysely
if (oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
return data;
},
{
mode: "sync",
id: "bknd-sort-update",
},
);
}
+1
View File
@@ -9,3 +9,4 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
export { sort, type SortPluginOptions } from "./data/sort.plugin";
+20 -22
View File
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
import { useTheme, type AppTheme } from "ui/client/use-theme"; import { useTheme, type AppTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client"; import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { Routes } from "./routes"; import { Routes } from "./routes";
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options"; import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
@@ -52,30 +52,26 @@ export type BkndAdminProps = {
children?: ReactNode; children?: ReactNode;
}; };
export default function Admin(props: BkndAdminProps) { export default function Admin({
baseUrl: baseUrlOverride,
withProvider = false,
config: _config = {},
children,
}: BkndAdminProps) {
const { theme } = useTheme();
const Provider = ({ children }: any) => const Provider = ({ children }: any) =>
props.withProvider ? ( withProvider ? (
<ClientProvider <ClientProvider
baseUrl={props.baseUrl} baseUrl={baseUrlOverride}
{...(typeof props.withProvider === "object" ? props.withProvider : {})} {...(typeof withProvider === "object" ? withProvider : {})}
> >
{children} {children}
</ClientProvider> </ClientProvider>
) : ( ) : (
children children
); );
return (
<Provider>
<AdminInner {...props} />
</Provider>
);
}
function AdminInner(props: BkndAdminProps) {
const { theme } = useTheme();
const config = { const config = {
...props.config, ..._config,
...useBkndWindowContext(), ...useBkndWindowContext(),
}; };
@@ -86,12 +82,14 @@ function AdminInner(props: BkndAdminProps) {
); );
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <Provider>
<Notifications position="top-right" /> <MantineProvider {...createMantineTheme(theme as any)}>
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}> <Notifications position="top-right" />
{props.children} <Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
</Routes> {children}
</MantineProvider> </Routes>
</MantineProvider>
</Provider>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useApi } from "bknd/client"; import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
+2 -10
View File
@@ -14,20 +14,18 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: ReactNode; children?: ReactNode;
baseUrl?: string; baseUrl?: string;
api?: Api;
} & ApiOptions; } & ApiOptions;
export const ClientProvider = ({ export const ClientProvider = ({
children, children,
host, host,
baseUrl: _baseUrl = host, baseUrl: _baseUrl = host,
api: _api,
...props ...props
}: ClientProviderProps) => { }: ClientProviderProps) => {
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
const _ctx = useClientContext(); const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any; let user: any = undefined;
if (winCtx) { if (winCtx) {
user = winCtx.user; user = winCtx.user;
@@ -42,7 +40,6 @@ export const ClientProvider = ({
const apiProps = { user, ...props, host: actualBaseUrl }; const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo( const api = useMemo(
() => () =>
_api ??
new Api({ new Api({
...apiProps, ...apiProps,
verbose: isDebug(), verbose: isDebug(),
@@ -53,7 +50,7 @@ export const ClientProvider = ({
} }
}, },
}), }),
[_api, JSON.stringify(apiProps)], [JSON.stringify(apiProps)],
); );
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState()); const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
@@ -67,14 +64,9 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => { export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext); const context = useContext(ClientContext);
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
console.info("creating new api", { host });
return new Api({ host: host ?? "" }); return new Api({ host: host ?? "" });
} }
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
return context.api; return context.api;
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { useApi } from "../ClientProvider"; import { useApi } from "ui/client";
import { useState } from "react"; import { useState } from "react";
export const useApiQuery = < export const useApiQuery = <
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
import { objectTransform, encodeSearch } from "bknd/utils"; import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import type { Insertable, Selectable, Updateable, Generated } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "bknd/client"; import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error { export class UseEntityApiError<Payload = any> extends Error {
constructor( constructor(
-1
View File
@@ -4,7 +4,6 @@ export {
type ClientProviderProps, type ClientProviderProps,
useApi, useApi,
useBaseUrl, useBaseUrl,
useClientContext
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
+2 -3
View File
@@ -1,6 +1,7 @@
import type { AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "bknd"; import type { AuthResponse } from "bknd";
import { useApi, useInvalidate, useClientContext } from "bknd/client"; import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -18,7 +19,6 @@ type UseAuth = {
logout: () => Promise<void>; logout: () => Promise<void>;
verify: () => Promise<void>; verify: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
local: boolean;
}; };
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
@@ -61,6 +61,5 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
logout, logout,
setToken, setToken,
verify, verify,
local: !!api.options.storage,
}; };
}; };
+2 -1
View File
@@ -1,5 +1,6 @@
import type React from "react"; import type React from "react";
import { Children, forwardRef } from "react"; import { Children } from "react";
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
@@ -1,6 +1,6 @@
import { Tooltip } from "@mantine/core"; import { Tooltip } from "@mantine/core";
import clsx from "clsx"; import clsx from "clsx";
import { getBrowser } from "bknd/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data/fields"; import type { Field } from "data/fields";
import { Switch as RadixSwitch } from "radix-ui"; import { Switch as RadixSwitch } from "radix-ui";
import { import {
@@ -16,18 +16,15 @@ import {
setPath, setPath,
} from "./utils"; } from "./utils";
export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & { export type NativeFormProps = {
hiddenSubmit?: boolean; hiddenSubmit?: boolean;
validateOn?: "change" | "submit"; validateOn?: "change" | "submit";
errorFieldSelector?: (selector: string) => any | null; errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
reportValidity?: boolean; reportValidity?: boolean;
onSubmit?: ( onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void;
onSubmitInvalid?: ( onSubmitInvalid?: (
errors: InputError[], errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement }, ctx: { event: FormEvent<HTMLFormElement> },
) => Promise<void> | void; ) => Promise<void> | void;
onError?: (errors: InputError[]) => void; onError?: (errors: InputError[]) => void;
disableSubmitOnError?: boolean; disableSubmitOnError?: boolean;
@@ -36,7 +33,7 @@ export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange"
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }, ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
) => Promise<void> | void; ) => Promise<void> | void;
clean?: CleanOptions | true; clean?: CleanOptions | true;
}; } & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
export type InputError = { export type InputError = {
name: string; name: string;
@@ -191,12 +188,12 @@ export function NativeForm({
const errors = validate({ report: true }); const errors = validate({ report: true });
if (errors.length > 0) { if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e, form }); onSubmitInvalid?.(errors, { event: e });
return; return;
} }
if (onSubmit) { if (onSubmit) {
await onSubmit(getFormValues(), { event: e, form }); await onSubmit(getFormValues(), { event: e });
} else { } else {
form.submit(); form.submit();
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { type LinkProps, Link as WouterLink, useRouter } from "wouter"; import { useInsertionEffect, useRef } from "react";
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
/* /*
+13 -43
View File
@@ -1,16 +1,13 @@
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx"; import clsx from "clsx";
import { NativeForm } from "ui/components/form/native-form/NativeForm"; import { NativeForm } from "ui/components/form/native-form/NativeForm";
import { transformObject } from "bknd/utils"; import { transform } from "lodash-es";
import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Password, Label } from "ui/components/form/Formy/components"; import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
import { useAuth } from "bknd/client";
import { Alert } from "ui/components/display/Alert";
import { useLocation } from "wouter";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "action"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string; className?: string;
formData?: any; formData?: any;
action: "login" | "register"; action: "login" | "register";
@@ -26,49 +23,24 @@ export function AuthForm({
action, action,
auth, auth,
buttonLabel = action === "login" ? "Sign in" : "Sign up", buttonLabel = action === "login" ? "Sign in" : "Sign up",
onSubmit: _onSubmit,
...props ...props
}: LoginFormProps) { }: LoginFormProps) {
const $auth = useAuth();
const basepath = auth?.basepath ?? "/api/auth"; const basepath = auth?.basepath ?? "/api/auth";
const [error, setError] = useState<string>();
const [, navigate] = useLocation();
const password = { const password = {
action: `${basepath}/password/${action}`, action: `${basepath}/password/${action}`,
strategy: auth?.strategies?.password ?? ({ type: "password" } as const), strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
}; };
const oauth = transformObject(auth?.strategies ?? {}, (value) => { const oauth = transform(
return value.type !== "password" ? value.config : undefined; auth?.strategies ?? {},
}) as Record<string, AppAuthOAuthStrategy>; (result, value, key) => {
const has_oauth = Object.keys(oauth).length > 0; if (value.type !== "password") {
result[key] = value.config;
async function onSubmit(
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) {
if ($auth?.local) {
ctx.event.preventDefault();
const res = await $auth.login(data);
if ("token" in res) {
navigate("/");
} else {
setError((res as any).error);
return;
} }
} },
{},
await _onSubmit?.(ctx.event); ) as Record<string, AppAuthOAuthStrategy>;
// submit form const has_oauth = Object.keys(oauth).length > 0;
ctx.form.submit();
}
useEffect(() => {
if ($auth.user) {
navigate("/");
}
}, [$auth.user]);
return ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
@@ -91,19 +63,17 @@ export function AuthForm({
<NativeForm <NativeForm
method={method} method={method}
action={password.action} action={password.action}
onSubmit={onSubmit}
{...(props as any)} {...(props as any)}
validateOn="change" validateOn="change"
className={clsx("flex flex-col gap-3 w-full", className)} className={clsx("flex flex-col gap-3 w-full", className)}
> >
{error && <Alert.Exception message={error} className="justify-center" />}
<Group> <Group>
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Email address</Label>
<Input type="email" name="email" required /> <Input type="email" name="email" required />
</Group> </Group>
<Group> <Group>
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Password name="password" required minLength={1} /> <Password name="password" required minLength={8} />
</Group> </Group>
<Button <Button
+1 -1
View File
@@ -1,4 +1,4 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "bknd/utils"; import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import type { IconType } from "ui/components/buttons/IconButton"; import type { IconType } from "ui/components/buttons/IconButton";
+2 -4
View File
@@ -1,11 +1,9 @@
import type { AppAuthSchema } from "auth/auth-schema"; import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useApi } from "bknd/client"; import { useApi } from "ui/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">; type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
baseUrl?: string;
}): Partial<AuthStrategyData> & {
loading: boolean; loading: boolean;
} => { } => {
const [data, setData] = useState<AuthStrategyData>(); const [data, setData] = useState<AuthStrategyData>();
+1 -1
View File
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
import { type FileWithPath, useDropzone } from "./use-dropzone"; import { type FileWithPath, useDropzone } from "./use-dropzone";
import { checkMaxReached } from "./helper"; import { checkMaxReached } from "./helper";
import { DropzoneInner } from "./DropzoneInner"; import { DropzoneInner } from "./DropzoneInner";
import { createDropzoneStore } from "./dropzone-state"; import { createDropzoneStore } from "ui/elements/media/dropzone-state";
import { useStore } from "zustand"; import { useStore } from "zustand";
export type FileState = { export type FileState = {
+22 -19
View File
@@ -1,8 +1,9 @@
import type { Api } from "bknd/client";
import type { PrimaryFieldType, RepoQueryIn } from "bknd"; import type { PrimaryFieldType, RepoQueryIn } from "bknd";
import type { MediaFieldSchema } from "media/AppMedia"; import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema"; import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react"; import { useId, useEffect, useRef, useState } from "react";
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone"; import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper"; import { mediaItemsToFileStates } from "./helper";
@@ -131,24 +132,26 @@ export function DropzoneContainer({
} }
return ( return (
<Dropzone <>
key={key} <Dropzone
getUploadInfo={getUploadInfo} key={key}
handleDelete={handleDelete} getUploadInfo={getUploadInfo}
autoUpload handleDelete={handleDelete}
initialItems={_initialItems} autoUpload
footer={ initialItems={_initialItems}
infinite && footer={
"setSize" in $q && ( infinite &&
<Footer "setSize" in $q && (
items={_initialItems.length} <Footer
length={placeholderLength} items={_initialItems.length}
onFirstVisible={() => $q.setSize($q.size + 1)} length={placeholderLength}
/> onFirstVisible={() => $q.setSize($q.size + 1)}
) />
} )
{...props} }
/> {...props}
/>
</>
); );
} }
+2 -2
View File
@@ -19,8 +19,8 @@ import {
} from "react-icons/tb"; } from "react-icons/tb";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "core/utils";
import type { DropzoneRenderProps, FileState } from "./Dropzone"; import type { DropzoneRenderProps, FileState } from "ui/elements";
import { useDropzoneFileState, useDropzoneState } from "./Dropzone"; import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
function handleUploadError(e: unknown) { function handleUploadError(e: unknown) {
+7 -20
View File
@@ -474,7 +474,6 @@ type SectionHeaderAccordionItemProps = {
ActiveIcon?: any; ActiveIcon?: any;
children?: React.ReactNode; children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
scrollContainerRef?: React.RefObject<HTMLDivElement>;
}; };
export const SectionHeaderAccordionItem = ({ export const SectionHeaderAccordionItem = ({
@@ -484,7 +483,6 @@ export const SectionHeaderAccordionItem = ({
ActiveIcon = IconChevronUp, ActiveIcon = IconChevronUp,
children, children,
renderHeaderRight, renderHeaderRight,
scrollContainerRef,
}: SectionHeaderAccordionItemProps) => ( }: SectionHeaderAccordionItemProps) => (
<div <div
style={{ minHeight: 49 }} style={{ minHeight: 49 }}
@@ -495,8 +493,6 @@ export const SectionHeaderAccordionItem = ({
: "flex-initial cursor-pointer hover:bg-primary/5", : "flex-initial cursor-pointer hover:bg-primary/5",
)} )}
> >
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
<div <div
className={twMerge( className={twMerge(
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2", "flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
@@ -505,12 +501,14 @@ export const SectionHeaderAccordionItem = ({
> >
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} /> <IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2> <h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
<div className="flex grow" /> <div className="flex flex-grow" />
{renderHeaderRight?.({ open })} {renderHeaderRight?.({ open })}
</div> </div>
<div <div
ref={scrollContainerRef} className={twMerge(
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")} "overflow-y-scroll transition-all",
open ? " flex-grow" : "h-0 opacity-0",
)}
> >
{children} {children}
</div> </div>
@@ -520,25 +518,14 @@ export const SectionHeaderAccordionItem = ({
export const RouteAwareSectionHeaderAccordionItem = ({ export const RouteAwareSectionHeaderAccordionItem = ({
routePattern, routePattern,
identifier, identifier,
renderHeaderRight,
...props ...props
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & { }: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
// it's optional because it could be provided using the context // it's optional because it could be provided using the context
routePattern?: string; routePattern?: string;
identifier: string; identifier: string;
}) => { }) => {
const { active, toggle } = useRoutePathState(routePattern, identifier); const { active, toggle } = useRoutePathState(routePattern, identifier);
return ( return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
<SectionHeaderAccordionItem
{...props}
open={active}
toggle={toggle}
renderHeaderRight={
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
}
/>
);
}; };
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => ( export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
+15 -18
View File
@@ -1,4 +1,4 @@
import { SegmentedControl } from "@mantine/core"; import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react"; import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { import {
TbDatabase, TbDatabase,
@@ -10,7 +10,7 @@ import {
TbUser, TbUser,
TbX, TbX,
} from "react-icons/tb"; } from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "bknd/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
@@ -24,35 +24,34 @@ import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils"; import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store"; import { appShellStore } from "ui/store";
import { getVersion, isDebug } from "core/env"; import { getVersion } from "core/env";
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon"; import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
import { useAppShellAdminOptions } from "ui/options"; import { useAppShellAdminOptions } from "ui/options";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const { config } = useBknd();
const items: { const items: {
label: string; label: string;
href: string; href: string;
Icon?: any; Icon: any;
exact?: boolean; exact?: boolean;
tooltip?: string; tooltip?: string;
disabled?: boolean; disabled?: boolean;
}[] = [ }[] = [
/*{
label: "Base",
href: "#",
exact: true,
Icon: TbLayoutDashboard,
disabled: true,
tooltip: "Coming soon"
},*/
{ label: "Data", href: "/data", Icon: TbDatabase }, { label: "Data", href: "/data", Icon: TbDatabase },
{ label: "Auth", href: "/auth", Icon: TbFingerprint }, { label: "Auth", href: "/auth", Icon: TbFingerprint },
{ label: "Media", href: "/media", Icon: TbPhoto }, { label: "Media", href: "/media", Icon: TbPhoto },
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
]; ];
if (isDebug() || Object.keys(config.flows?.flows ?? {}).length > 0) {
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
}
if (config.server.mcp.enabled) {
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
}
const activeItem = items.find((item) => const activeItem = items.find((item) =>
item.exact ? location === item.href : location.startsWith(item.href), item.exact ? location === item.href : location.startsWith(item.href),
); );
@@ -155,10 +154,8 @@ function UserMenu() {
async function handleLogout() { async function handleLogout() {
await auth.logout(); await auth.logout();
// @todo: grab from somewhere constant
if (!auth.local) { navigate(logout_route, { reload: true });
navigate(logout_route, { reload: true });
}
} }
async function handleLogin() { async function handleLogin() {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ContextModalProps } from "@mantine/modals"; import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react"; import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useEntityQuery } from "bknd/client"; import { useEntityQuery } from "ui/client";
import { type FileState, Media } from "ui/elements"; import { type FileState, Media } from "ui/elements";
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -1,4 +1,4 @@
import { useApi, useInvalidate } from "bknd/client"; import { useApi, useInvalidate } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals"; import { bkndModals } from "ui/modals";
@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
import type { RelationField } from "data/relations"; import type { RelationField } from "data/relations";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb"; import { TbEye } from "react-icons/tb";
import { useEntityQuery } from "bknd/client"; import { useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
+1 -1
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb"; import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
import { useApiQuery } from "bknd/client"; import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button"; import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
@@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { cn } from "ui/lib/utils"; import { cn } from "ui/lib/utils";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import { mountOnce, useApiQuery } from "bknd/client"; import { mountOnce, useApiQuery } from "ui/client";
import { CodePreview } from "ui/components/code/CodePreview"; import { CodePreview } from "ui/components/code/CodePreview";
import type { JsonError } from "json-schema-library"; import type { JsonError } from "json-schema-library";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
@@ -378,7 +378,11 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
}); });
} }
const Policy = ({ permission }: { permission: TPermission }) => { const Policy = ({
permission,
}: {
permission: TPermission;
}) => {
const { value } = useDerivedFieldContext("", ({ value }) => ({ const { value } = useDerivedFieldContext("", ({ value }) => ({
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter", effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
})); }));
@@ -499,24 +503,22 @@ const CustomFieldWrapper = ({
className: "max-w-none", className: "max-w-none",
}} }}
position="bottom-end" position="bottom-end"
target={() => ( target={() =>
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll"> typeof schema.content === "object" ? (
{typeof schema.content === "object" ? ( <JsonViewer
<JsonViewer className="w-auto max-w-120 bg-background pr-3 text-sm"
className="pr-3 text-sm" json={schema.content}
json={schema.content} title={schema.name}
title={schema.name} expand={5}
expand={5} />
/> ) : (
) : ( <CodePreview
<CodePreview code={schema.content}
code={schema.content} lang="typescript"
lang="typescript" className="w-auto max-w-120 bg-background p-3 text-sm"
className="p-3 text-sm" />
/> )
)} }
</div>
)}
> >
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}> <Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
{autoFormatString(schema.name)} {autoFormatString(schema.name)}
+1 -1
View File
@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
import type { Entity, EntityData, EntityRelation } from "bknd"; import type { Entity, EntityData, EntityRelation } from "bknd";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "bknd/client"; import { useApiQuery, useEntityQuery } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -1,6 +1,6 @@
import type { EntityData } from "bknd"; import type { EntityData } from "bknd";
import { useState } from "react"; import { useState } from "react";
import { useEntityMutate } from "bknd/client"; import { useEntityMutate } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
@@ -2,7 +2,7 @@ import type { Entity } from "bknd";
import { repoQuery } from "data/server/query"; import { repoQuery } from "data/server/query";
import { Fragment } from "react"; import { Fragment } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery } from "bknd/client"; import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
@@ -5,12 +5,12 @@ import {
ucFirstAllSnakeToPascalWithSpaces, ucFirstAllSnakeToPascalWithSpaces,
s, s,
stringIdentifier, stringIdentifier,
pickKeys,
} from "bknd/utils"; } from "bknd/utils";
import { import {
type TAppDataEntityFields, type TAppDataEntityFields,
fieldsSchemaObject as originalFieldsSchemaObject, fieldsSchemaObject as originalFieldsSchemaObject,
} from "data/data-schema"; } from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, memo, useEffect, useImperativeHandle } from "react"; import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb"; import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
@@ -317,6 +317,7 @@ function EntityField({
const name = watch(`fields.${index}.name`); const name = watch(`fields.${index}.name`);
const { active, toggle } = useRoutePathState(routePattern ?? "", name); const { active, toggle } = useRoutePathState(routePattern ?? "", name);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!; const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || []; const disabled = fieldSpec.disabled || [];
const hidden = fieldSpec.hidden || []; const hidden = fieldSpec.hidden || [];
const dragDisabled = index === 0; const dragDisabled = index === 0;
@@ -475,7 +476,7 @@ function EntityField({
field={field} field={field}
onChange={(value) => { onChange={(value) => {
setValue(`${prefix}.config`, { setValue(`${prefix}.config`, {
...pickKeys(getValues([`${prefix}.config`])[0], commonProps), ...getValues([`fields.${index}.config`])[0],
...value, ...value,
}); });
}} }}
@@ -519,7 +520,7 @@ const SpecificForm = ({
readonly?: boolean; readonly?: boolean;
}) => { }) => {
const type = field.field.type; const type = field.field.type;
const specificData = omitKeys(field.field.config ?? {}, commonProps); const specificData = omit(field.field.config, commonProps);
return ( return (
<JsonSchemaForm <JsonSchemaForm
+7 -14
View File
@@ -11,16 +11,11 @@ import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register"; import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals"; import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "bknd/client"; import { useBkndWindowContext } from "ui/client";
import ToolsRoutes from "./tools"; import ToolsRoutes from "./tools";
// @ts-ignore // @ts-ignore
let TestRoutes: any; const TestRoutes = lazy(() => import("./test"));
try {
if (import.meta.env.DEV) {
TestRoutes = lazy(() => import("./test"));
}
} catch {}
export function Routes({ export function Routes({
BkndWrapper, BkndWrapper,
@@ -48,13 +43,11 @@ export function Routes({
<Route path="/" nest> <Route path="/" nest>
<Root> <Root>
<Switch> <Switch>
{TestRoutes && ( <Route path="/test*" nest>
<Route path="/test*" nest> <Suspense fallback={null}>
<Suspense fallback={null}> <TestRoutes />
<TestRoutes /> </Suspense>
</Suspense> </Route>
</Route>
)}
{children} {children}
+2 -3
View File
@@ -1,12 +1,11 @@
import { IconPhoto } from "@tabler/icons-react"; import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { type FileState, Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { bkndModals } from "ui/modals"; import { bkndModals } from "ui/modals";
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
import type { FileState } from "ui/elements/media/Dropzone";
export function MediaIndex() { export function MediaIndex() {
const { config } = useBknd(); const { config } = useBknd();
@@ -36,7 +35,7 @@ export function MediaIndex() {
return ( return (
<AppShell.Scrollable> <AppShell.Scrollable>
<div className="flex flex-1 p-3"> <div className="flex flex-1 p-3">
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} /> <Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
); );
+1 -1
View File
@@ -1,6 +1,6 @@
import { IconHome } from "@tabler/icons-react"; import { IconHome } from "@tabler/icons-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuth } from "bknd/client"; import { useAuth } from "ui/client";
import { useEffectOnce } from "ui/hooks/use-effect"; import { useEffectOnce } from "ui/hooks/use-effect";
import { Empty } from "../components/display/Empty"; import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title"; import { useBrowserTitle } from "../hooks/use-browser-title";
@@ -49,21 +49,19 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
try { try {
const user_entity = config.entity_name ?? "users"; const user_entity = config.entity_name ?? "users";
const entities = _s.config.data.entities ?? {}; const entities = _s.config.data.entities ?? {};
console.log("entities", entities, user_entity);
const user_fields = Object.entries(entities[user_entity]?.fields ?? {}) const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined)) .map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean); .filter(Boolean);
if (user_fields.length > 0) { if (user_fields.length > 0) {
console.log("user_fields", user_fields);
_schema.properties.jwt.properties.fields.items.enum = user_fields; _schema.properties.jwt.properties.fields.items.enum = user_fields;
_schema.properties.jwt.properties.fields.uniqueItems = true; _schema.properties.jwt.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes"; uiSchema.jwt.fields["ui:widget"] = "checkboxes";
} }
} catch (e) {}
const roles = Object.keys(config.roles ?? {}); console.log("_s", _s);
if (roles.length > 0) {
_schema.properties.default_role_register.enum = roles;
}
} catch (_e) {}
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" }; const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
/* if (_s.permissions) { /* if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions; roleSchema.properties.permissions.items.enum = _s.permissions;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useApi, useApiQuery } from "bknd/client"; import { useApi, useApiQuery } from "ui/client";
import { Scrollable } from "ui/layouts/AppShell/AppShell"; import { Scrollable } from "ui/layouts/AppShell/AppShell";
function Bla() { function Bla() {

Some files were not shown because too many files have changed in this diff Show More