Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f6985e8f | ||
|
|
609eeade23 | ||
|
|
2eba931e39 | ||
|
|
54aae56734 |
@@ -16,8 +16,8 @@ jobs:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_DB: bknd
|
POSTGRES_DB: bknd
|
||||||
# ports:
|
ports:
|
||||||
# - 5430:5432
|
- 5430:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
{
|
{
|
||||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"biome.enabled": true,
|
"biome.enabled": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
//"source.organizeImports.biome": "explicit",
|
//"source.organizeImports.biome": "explicit",
|
||||||
"source.fixAll.biome": "explicit"
|
"source.fixAll.biome": "explicit"
|
||||||
},
|
},
|
||||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"js/ts.preferences.autoImportFileExcludePatterns": [
|
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/node_modules/**/dist/**",
|
"**/node_modules/**/dist/**",
|
||||||
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
||||||
],
|
],
|
||||||
"js/ts.preferences.includePackageJsonAutoImports": "on",
|
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||||
"js/ts.tsserver.watchOptions": {
|
"typescript.tsserver.watchOptions": {
|
||||||
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
|
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
|
||||||
},
|
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,72 +4,73 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } 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";
|
||||||
|
|
||||||
const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
|
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", () => {
|
describe("makeConfig", () => {
|
||||||
it("returns empty config for empty inputs", async () => {
|
it("returns empty config for empty inputs", async () => {
|
||||||
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
||||||
[{}],
|
[{}],
|
||||||
[{}, { env: { TEST: "test" } }],
|
[{}, { env: { TEST: "test" } }],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const args of cases) {
|
for (const args of cases) {
|
||||||
const cfg = await adapter.makeConfig(...(args as any));
|
const cfg = await adapter.makeConfig(...(args as any));
|
||||||
expect(stripConnection(cfg)).toEqual({});
|
expect(stripConnection(cfg)).toEqual({});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges app output into config", async () => {
|
||||||
|
const cfg = await adapter.makeConfig(
|
||||||
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
|
{ env: { TEST: "test" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stripConnection(cfg)).toEqual({
|
||||||
|
config: { server: { cors: { origin: "test" } } },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("merges app output into config", async () => {
|
it("allows all properties in app() result", async () => {
|
||||||
const cfg = await adapter.makeConfig(
|
const called = mock(() => null);
|
||||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
|
||||||
{ env: { TEST: "test" } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(stripConnection(cfg)).toEqual({
|
const cfg = await adapter.makeConfig(
|
||||||
|
{
|
||||||
|
app: (env) => ({
|
||||||
|
connection: { url: "test" },
|
||||||
config: { server: { cors: { origin: "test" } } },
|
config: { server: { cors: { origin: "test" } } },
|
||||||
});
|
options: { mode: "db" as const },
|
||||||
});
|
onBuilt: () => {
|
||||||
|
called();
|
||||||
it("allows all properties in app() result", async () => {
|
expect(env).toEqual({ foo: "bar" });
|
||||||
const called = mock(() => null);
|
|
||||||
|
|
||||||
const cfg = await adapter.makeConfig(
|
|
||||||
{
|
|
||||||
app: (env) => ({
|
|
||||||
connection: { url: "test" },
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
|
||||||
options: { mode: "db" as const },
|
|
||||||
onBuilt: () => {
|
|
||||||
called();
|
|
||||||
expect(env).toEqual({ foo: "bar" });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{ foo: "bar" },
|
}),
|
||||||
);
|
},
|
||||||
|
{ foo: "bar" },
|
||||||
|
);
|
||||||
|
|
||||||
expect(cfg.connection).toEqual({ url: "test" });
|
expect(cfg.connection).toEqual({ url: "test" });
|
||||||
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||||
expect(cfg.options).toEqual({ mode: "db" });
|
expect(cfg.options).toEqual({ mode: "db" });
|
||||||
|
|
||||||
await cfg.onBuilt?.({} as any);
|
await cfg.onBuilt?.({} as any);
|
||||||
expect(called).toHaveBeenCalledTimes(1);
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("adapter test suites", () => {
|
describe("adapter test suites", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,13 +73,7 @@ describe("Api", async () => {
|
|||||||
|
|
||||||
it("should extract tokens case insensitive", async () => {
|
it("should extract tokens case insensitive", async () => {
|
||||||
const token = await sign({ sub: "test" }, "1234");
|
const token = await sign({ sub: "test" }, "1234");
|
||||||
expect(
|
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState()
|
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
.token,
|
})
|
||||||
).toBe(token);
|
|
||||||
expect(
|
|
||||||
new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState()
|
|
||||||
.token,
|
|
||||||
).toBe(token);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,18 +51,14 @@ describe("Authenticator", async () => {
|
|||||||
});
|
});
|
||||||
const token = await auth.jwt({ sub: "test" });
|
const token = await auth.jwt({ sub: "test" });
|
||||||
|
|
||||||
const res = await auth.resolveAuthFromRequest(
|
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||||
new Headers({
|
Authorization: `Bearer ${token}`,
|
||||||
Authorization: `Bearer ${token}`,
|
}));
|
||||||
}),
|
expect((res as any).sub).toBe("test")
|
||||||
);
|
|
||||||
expect((res as any).sub).toBe("test");
|
|
||||||
|
|
||||||
const res2 = await auth.resolveAuthFromRequest(
|
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
||||||
new Headers({
|
Authorization: `bearer ${token}`,
|
||||||
Authorization: `bearer ${token}`,
|
}));
|
||||||
}),
|
expect((res2 as any).sub).toBe("test")
|
||||||
);
|
})
|
||||||
expect((res2 as any).sub).toBe("test");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ describe("Polymorphic", async () => {
|
|||||||
target: "media",
|
target: "media",
|
||||||
config: {
|
config: {
|
||||||
mappedBy: "image",
|
mappedBy: "image",
|
||||||
on_delete: "set null",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// media should not see categories
|
// media should not see categories
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import { $ } from "bun";
|
|||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
|
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
host: isCI ? "postgres" : "localhost",
|
host: "localhost",
|
||||||
port: 5432,
|
port: 5430,
|
||||||
user: "postgres",
|
user: "postgres",
|
||||||
password: "postgres",
|
password: "postgres",
|
||||||
database: "bknd",
|
database: "bknd",
|
||||||
@@ -41,11 +39,6 @@ async function isPostgresRunning() {
|
|||||||
|
|
||||||
describe("postgres", () => {
|
describe("postgres", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
|
|
||||||
console.log("Running in CI: Skipping manual docker run");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await isPostgresRunning())) {
|
if (!(await isPostgresRunning())) {
|
||||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
||||||
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import v7 from "./samples/v7.json";
|
|||||||
import v8 from "./samples/v8.json";
|
import v8 from "./samples/v8.json";
|
||||||
import v8_2 from "./samples/v8-2.json";
|
import v8_2 from "./samples/v8-2.json";
|
||||||
import v9 from "./samples/v9.json";
|
import v9 from "./samples/v9.json";
|
||||||
import v10 from "./samples/v10.json";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
import { CURRENT_VERSION } from "modules/db/migrations";
|
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -63,7 +61,7 @@ async function getRawConfig(
|
|||||||
return await db
|
return await db
|
||||||
.selectFrom("__bknd")
|
.selectFrom("__bknd")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("version", "=", opts?.version ?? CURRENT_VERSION)
|
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
||||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
@@ -117,6 +115,7 @@ describe("Migrations", () => {
|
|||||||
"^^s3.secret_access_key^^",
|
"^^s3.secret_access_key^^",
|
||||||
);
|
);
|
||||||
const [config, secrets] = (await getRawConfig(app, {
|
const [config, secrets] = (await getRawConfig(app, {
|
||||||
|
version: 10,
|
||||||
types: ["config", "secrets"],
|
types: ["config", "secrets"],
|
||||||
})) as any;
|
})) as any;
|
||||||
|
|
||||||
@@ -130,15 +129,4 @@ describe("Migrations", () => {
|
|||||||
"^^s3.secret_access_key^^",
|
"^^s3.secret_access_key^^",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("migration from 10 to 11", async () => {
|
|
||||||
expect(v10.version).toBe(10);
|
|
||||||
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
|
|
||||||
|
|
||||||
const app = await createVersionedApp(v10);
|
|
||||||
|
|
||||||
expect(app.version()).toBeGreaterThan(10);
|
|
||||||
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
|
|
||||||
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 10,
|
|
||||||
"server": {
|
|
||||||
"cors": {
|
|
||||||
"origin": "*",
|
|
||||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
|
||||||
"allow_headers": [
|
|
||||||
"Content-Type",
|
|
||||||
"Content-Length",
|
|
||||||
"Authorization",
|
|
||||||
"Accept"
|
|
||||||
],
|
|
||||||
"allow_credentials": true
|
|
||||||
},
|
|
||||||
"mcp": {
|
|
||||||
"enabled": true,
|
|
||||||
"path": "/api/system/mcp",
|
|
||||||
"logLevel": "warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"basepath": "/api/data",
|
|
||||||
"default_primary_format": "integer",
|
|
||||||
"entities": {
|
|
||||||
"test": {
|
|
||||||
"type": "regular",
|
|
||||||
"fields": {
|
|
||||||
"id": {
|
|
||||||
"type": "primary",
|
|
||||||
"config": {
|
|
||||||
"format": "integer",
|
|
||||||
"fillable": false,
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": ["read", "update"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "enum",
|
|
||||||
"config": {
|
|
||||||
"default_value": "INACTIVE",
|
|
||||||
"options": {
|
|
||||||
"type": "strings",
|
|
||||||
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
|
|
||||||
},
|
|
||||||
"required": true,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"type": "date",
|
|
||||||
"config": {
|
|
||||||
"type": "datetime",
|
|
||||||
"required": true,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schema": {
|
|
||||||
"type": "jsonschema",
|
|
||||||
"config": {
|
|
||||||
"default_from_schema": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"one": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"sort_field": "id",
|
|
||||||
"sort_dir": "asc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"items": {
|
|
||||||
"type": "regular",
|
|
||||||
"fields": {
|
|
||||||
"id": {
|
|
||||||
"type": "primary",
|
|
||||||
"config": {
|
|
||||||
"format": "integer",
|
|
||||||
"fillable": false,
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"sort_field": "id",
|
|
||||||
"sort_dir": "asc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"type": "system",
|
|
||||||
"fields": {
|
|
||||||
"id": {
|
|
||||||
"type": "primary",
|
|
||||||
"config": {
|
|
||||||
"format": "integer",
|
|
||||||
"fillable": false,
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": true,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"folder": {
|
|
||||||
"type": "boolean",
|
|
||||||
"config": {
|
|
||||||
"default_value": false,
|
|
||||||
"hidden": true,
|
|
||||||
"fillable": ["create"],
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mime_type": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"type": "number",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scope": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"hidden": true,
|
|
||||||
"fillable": ["create"],
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"etag": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modified_at": {
|
|
||||||
"type": "date",
|
|
||||||
"config": {
|
|
||||||
"type": "datetime",
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reference": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entity_id": {
|
|
||||||
"type": "text",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"type": "json",
|
|
||||||
"config": {
|
|
||||||
"required": false,
|
|
||||||
"fillable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"sort_field": "id",
|
|
||||||
"sort_dir": "asc"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"relations": {},
|
|
||||||
"indices": {
|
|
||||||
"idx_unique_media_path": {
|
|
||||||
"entity": "media",
|
|
||||||
"fields": ["path"],
|
|
||||||
"unique": true
|
|
||||||
},
|
|
||||||
"idx_media_reference": {
|
|
||||||
"entity": "media",
|
|
||||||
"fields": ["reference"],
|
|
||||||
"unique": false
|
|
||||||
},
|
|
||||||
"idx_media_entity_id": {
|
|
||||||
"entity": "media",
|
|
||||||
"fields": ["entity_id"],
|
|
||||||
"unique": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"enabled": false,
|
|
||||||
"basepath": "/api/auth",
|
|
||||||
"entity_name": "users",
|
|
||||||
"allow_register": true,
|
|
||||||
"jwt": {
|
|
||||||
"secret": "",
|
|
||||||
"alg": "HS256",
|
|
||||||
"fields": ["id", "email", "role"]
|
|
||||||
},
|
|
||||||
"cookie": {
|
|
||||||
"path": "/",
|
|
||||||
"sameSite": "lax",
|
|
||||||
"secure": true,
|
|
||||||
"httpOnly": true,
|
|
||||||
"expires": 604800,
|
|
||||||
"partitioned": false,
|
|
||||||
"renew": true,
|
|
||||||
"pathSuccess": "/",
|
|
||||||
"pathLoggedOut": "/"
|
|
||||||
},
|
|
||||||
"strategies": {
|
|
||||||
"password": {
|
|
||||||
"type": "password",
|
|
||||||
"enabled": true,
|
|
||||||
"config": {
|
|
||||||
"hashing": "sha256"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"guard": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"roles": {}
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"flows": {
|
|
||||||
"basepath": "/api/flows",
|
|
||||||
"flows": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,15 +14,15 @@ const clean = args.includes("--clean");
|
|||||||
|
|
||||||
// silence tsup
|
// silence tsup
|
||||||
const oldConsole = {
|
const oldConsole = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
};
|
};
|
||||||
console.log = () => {};
|
console.log = () => {};
|
||||||
console.warn = () => {};
|
console.warn = () => {};
|
||||||
|
|
||||||
const define = {
|
const define = {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
@@ -43,146 +43,149 @@ if (clean) {
|
|||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
function buildTypes() {
|
function buildTypes() {
|
||||||
if (types_running || !types) return;
|
if (types_running || !types) return;
|
||||||
types_running = true;
|
types_running = true;
|
||||||
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types && !watch) {
|
if (types && !watch) {
|
||||||
buildTypes();
|
buildTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch || !types) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencies = Object.keys(pkg.dependencies);
|
const dependencies = Object.keys(pkg.dependencies);
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = [
|
const external = [
|
||||||
...dependencies,
|
...dependencies,
|
||||||
"bun:test",
|
"bun:test",
|
||||||
"node:test",
|
"node:test",
|
||||||
"node:assert/strict",
|
"node:assert/strict",
|
||||||
"@libsql/client",
|
"@libsql/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"jsonv-ts",
|
"jsonv-ts",
|
||||||
/^jsonv-ts\/.*/,
|
/^jsonv-ts\/.*/,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
async function buildApi() {
|
async function buildApi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
// don't use tsup's broken watch, we'll handle it ourselves
|
// don't use tsup's broken watch, we'll handle it ourselves
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: [
|
entry: [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/core/utils/index.ts",
|
"src/core/utils/index.ts",
|
||||||
"src/plugins/index.ts",
|
"src/plugins/index.ts",
|
||||||
"src/modes/index.ts",
|
"src/modes/index.ts",
|
||||||
],
|
],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: [...external],
|
external: [...external],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
removeNodeProtocol: false,
|
removeNodeProtocol: false,
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteClient(path: string) {
|
async function rewriteClient(path: string) {
|
||||||
const bundle = await Bun.file(path).text();
|
const bundle = await Bun.file(path).text();
|
||||||
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
await Bun.write(
|
||||||
|
path,
|
||||||
|
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
async function buildUi() {
|
async function buildUi() {
|
||||||
const base = {
|
const base = {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
/codemirror/,
|
/codemirror/,
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
"@mantine/core",
|
"@mantine/core",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.logLevel = "silent";
|
options.logLevel = "silent";
|
||||||
},
|
},
|
||||||
} satisfies tsup.Options;
|
} satisfies tsup.Options;
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/index.js");
|
await rewriteClient("./dist/ui/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/client/index.ts"],
|
entry: ["src/ui/client/index.ts"],
|
||||||
outDir: "dist/ui/client",
|
outDir: "dist/ui/client",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/client/index.js");
|
await rewriteClient("./dist/ui/client/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,186 +194,195 @@ async function buildUi() {
|
|||||||
* - ui/client is external, and after built replaced with "bknd/client"
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
*/
|
*/
|
||||||
async function buildUiElements() {
|
async function buildUiElements() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"wouter",
|
"wouter",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
// not important for elements, mock to reduce bundle
|
// not important for elements, mock to reduce bundle
|
||||||
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/elements/index.js");
|
await rewriteClient("./dist/ui/elements/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
function baseConfig(
|
||||||
return {
|
adapter: string,
|
||||||
minify,
|
overrides: Partial<tsup.Options> = {},
|
||||||
sourcemap,
|
): tsup.Options {
|
||||||
watch: false,
|
return {
|
||||||
entry: [`src/adapter/${adapter}/index.ts`],
|
minify,
|
||||||
format: ["esm"],
|
sourcemap,
|
||||||
platform: "neutral",
|
watch: false,
|
||||||
outDir: `dist/adapter/${adapter}`,
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
metafile: true,
|
format: ["esm"],
|
||||||
splitting: false,
|
platform: "neutral",
|
||||||
removeNodeProtocol: false,
|
outDir: `dist/adapter/${adapter}`,
|
||||||
onSuccess: async () => {
|
metafile: true,
|
||||||
delayTypes();
|
splitting: false,
|
||||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
removeNodeProtocol: false,
|
||||||
},
|
onSuccess: async () => {
|
||||||
...overrides,
|
delayTypes();
|
||||||
define: {
|
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||||
...define,
|
},
|
||||||
...overrides.define,
|
...overrides,
|
||||||
},
|
define: {
|
||||||
external: [
|
...define,
|
||||||
/^cloudflare*/,
|
...overrides.define,
|
||||||
/^@?hono.*?/,
|
},
|
||||||
/^(bknd|react|next|node).*?/,
|
external: [
|
||||||
/.*\.(html)$/,
|
/^cloudflare*/,
|
||||||
...external,
|
/^@?hono.*?/,
|
||||||
...(Array.isArray(overrides.external) ? overrides.external : []),
|
/^(bknd|react|next|node).*?/,
|
||||||
],
|
/.*\.(html)$/,
|
||||||
};
|
...external,
|
||||||
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAdapters() {
|
async function buildAdapters() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// base adapter handles
|
// base adapter handles
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig(""),
|
...baseConfig(""),
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
entry: ["src/adapter/index.ts"],
|
entry: ["src/adapter/index.ts"],
|
||||||
outDir: "dist/adapter",
|
outDir: "dist/adapter",
|
||||||
// only way to keep @vite-ignore comments
|
// only way to keep @vite-ignore comments
|
||||||
minify: false,
|
minify: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("browser", {
|
baseConfig("browser", {
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
external: [/^sqlocal\/?.*?/, "wouter"],
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("bun", {
|
|
||||||
external: [/^bun\:.*/],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(baseConfig("astro")),
|
|
||||||
tsup.build(baseConfig("aws")),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare", {
|
|
||||||
external: ["wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare/proxy", {
|
|
||||||
target: "esnext",
|
|
||||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
|
||||||
outDir: "dist/adapter/cloudflare",
|
|
||||||
metafile: false,
|
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("vite"),
|
|
||||||
platform: "node",
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("nextjs"),
|
baseConfig("bun", {
|
||||||
platform: "node",
|
external: [/^bun\:.*/],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(baseConfig("astro")),
|
||||||
...baseConfig("tanstack-start"),
|
tsup.build(baseConfig("aws")),
|
||||||
platform: "node",
|
tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: ["wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("sveltekit"),
|
baseConfig("cloudflare/proxy", {
|
||||||
platform: "node",
|
target: "esnext",
|
||||||
|
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||||
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("nuxt"),
|
...baseConfig("vite"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("node"),
|
...baseConfig("nextjs"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("solid-start"),
|
...baseConfig("tanstack-start"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/edge"),
|
...baseConfig("web"),
|
||||||
entry: ["src/adapter/sqlite/edge.ts"],
|
platform: "neutral",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/node"),
|
...baseConfig("sveltekit"),
|
||||||
entry: ["src/adapter/sqlite/node.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
platform: "node",
|
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/bun"),
|
...baseConfig("nuxt"),
|
||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
metafile: false,
|
|
||||||
external: [/^bun\:.*/],
|
tsup.build({
|
||||||
}),
|
...baseConfig("node"),
|
||||||
]);
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/edge"),
|
||||||
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/node"),
|
||||||
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/bun"),
|
||||||
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
external: [/^bun\:.*/],
|
||||||
|
}),
|
||||||
|
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAll() {
|
async function buildAll() {
|
||||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
await Promise.all([
|
||||||
|
buildApi(),
|
||||||
|
buildUi(),
|
||||||
|
buildUiElements(),
|
||||||
|
buildAdapters(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial build
|
// initial build
|
||||||
@@ -378,39 +390,47 @@ await buildAll();
|
|||||||
|
|
||||||
// custom watcher since tsup's watch is broken in 8.3.5+
|
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||||
if (watch) {
|
if (watch) {
|
||||||
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let isBuilding = false;
|
let isBuilding = false;
|
||||||
|
|
||||||
const rebuild = async () => {
|
const rebuild = async () => {
|
||||||
if (isBuilding) return;
|
if (isBuilding) return;
|
||||||
isBuilding = true;
|
isBuilding = true;
|
||||||
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||||
try {
|
try {
|
||||||
await buildAll();
|
await buildAll();
|
||||||
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||||
}
|
}
|
||||||
isBuilding = false;
|
isBuilding = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedRebuild = () => {
|
const debouncedRebuild = () => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(rebuild, 100);
|
debounceTimer = setTimeout(rebuild, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// watch src directory recursively
|
// watch src directory recursively
|
||||||
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
fsWatch(
|
||||||
|
join(import.meta.dir, "src"),
|
||||||
|
{ recursive: true },
|
||||||
|
(event, filename) => {
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
// ignore non-source files
|
// ignore non-source files
|
||||||
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
if (
|
||||||
return;
|
!filename.endsWith(".ts") &&
|
||||||
|
!filename.endsWith(".tsx") &&
|
||||||
|
!filename.endsWith(".css")
|
||||||
|
)
|
||||||
|
return;
|
||||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||||
debouncedRebuild();
|
debouncedRebuild();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// keep process alive
|
// keep process alive
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import path from "node:path";
|
|||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
|
||||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
type RunOptions = Omit<
|
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
||||||
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
|
|
||||||
"stdout" | "stderr"
|
|
||||||
>;
|
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
cmd: string[] | string,
|
cmd: string[] | string,
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
const adapter = process.env.TEST_ADAPTER;
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
const default_config = {
|
const default_config = {
|
||||||
media_adapter: "local",
|
media_adapter: "local",
|
||||||
base_path: "",
|
base_path: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const configs = {
|
const configs = {
|
||||||
cloudflare: {
|
cloudflare: {
|
||||||
media_adapter: "r2",
|
media_adapter: "r2",
|
||||||
},
|
},
|
||||||
"react-router": {
|
"react-router": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nextjs: {
|
nextjs: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nuxt: {
|
meta: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
"solid-start": {
|
nuxt: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
astro: {
|
astro: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
bun: {
|
bun: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
"tanstack-start": {
|
"tanstack-start": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAdapterConfig(): typeof default_config {
|
export function getAdapterConfig(): typeof default_config {
|
||||||
if (adapter) {
|
if (adapter) {
|
||||||
if (!configs[adapter]) {
|
if (!configs[adapter]) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...default_config,
|
...default_config,
|
||||||
...configs[adapter],
|
...configs[adapter],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default_config;
|
return default_config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,11 @@
|
|||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.js"
|
"require": "./dist/adapter/nextjs/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/web": {
|
||||||
|
"types": "./dist/types/adapter/web/index.d.ts",
|
||||||
|
"import": "./dist/adapter/web/index.js",
|
||||||
|
"require": "./dist/adapter/web/index.js"
|
||||||
|
},
|
||||||
"./adapter/nuxt": {
|
"./adapter/nuxt": {
|
||||||
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
||||||
"import": "./dist/adapter/nuxt/index.js",
|
"import": "./dist/adapter/nuxt/index.js",
|
||||||
@@ -263,11 +268,6 @@
|
|||||||
"import": "./dist/adapter/sveltekit/index.js",
|
"import": "./dist/adapter/sveltekit/index.js",
|
||||||
"require": "./dist/adapter/sveltekit/index.js"
|
"require": "./dist/adapter/sveltekit/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/solid-start": {
|
|
||||||
"types": "./dist/types/adapter/solid-start/index.d.ts",
|
|
||||||
"import": "./dist/adapter/solid-start/index.js",
|
|
||||||
"require": "./dist/adapter/solid-start/index.js"
|
|
||||||
},
|
|
||||||
"./adapter/aws": {
|
"./adapter/aws": {
|
||||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||||
"import": "./dist/adapter/aws/index.js",
|
"import": "./dist/adapter/aws/index.js",
|
||||||
@@ -297,13 +297,13 @@
|
|||||||
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||||
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||||
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||||
|
"adapter/web": ["./dist/types/adapter/web/index.d.ts"],
|
||||||
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
||||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||||
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||||
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
||||||
"adapter/solid-start": ["./dist/types/adapter/solid-start/index.d.ts"],
|
|
||||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -337,7 +337,6 @@
|
|||||||
"sveltekit",
|
"sveltekit",
|
||||||
"svelte",
|
"svelte",
|
||||||
"bun",
|
"bun",
|
||||||
"node",
|
"node"
|
||||||
"solid-start"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export async function getApp<Env>(
|
|||||||
* @param config - bknd configuration
|
* @param config - bknd configuration
|
||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
|
export function serve<Env>(
|
||||||
|
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
return async (request: Request) => {
|
return async (request: Request) => {
|
||||||
return (await getApp(config, args)).fetch(request);
|
return (await getApp(config, args)).fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./solid-start.adapter";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe } from "bun:test";
|
|
||||||
import * as solidStart from "./solid-start.adapter";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import type { SolidStartBkndConfig } from "./solid-start.adapter";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("solid-start adapter", () => {
|
|
||||||
adapterTestSuite<SolidStartBkndConfig>(bunTestRunner, {
|
|
||||||
makeApp: solidStart.getApp,
|
|
||||||
makeHandler: solidStart.serve,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
|
||||||
|
|
||||||
export type SolidStartEnv = NodeJS.ProcessEnv;
|
|
||||||
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bknd app instance
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables
|
|
||||||
*/
|
|
||||||
export async function getApp<Env = SolidStartEnv>(
|
|
||||||
config: SolidStartBkndConfig<Env>,
|
|
||||||
args: Env = process.env as Env,
|
|
||||||
) {
|
|
||||||
return await createRuntimeApp(config, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create middleware handler for Solid Start
|
|
||||||
* @param config - bknd configuration
|
|
||||||
* @param args - environment variables
|
|
||||||
*/
|
|
||||||
export function serve<Env = SolidStartEnv>(
|
|
||||||
config: SolidStartBkndConfig<Env> = {},
|
|
||||||
args: Env = process.env as Env,
|
|
||||||
) {
|
|
||||||
return async (req: Request) => {
|
|
||||||
const app = await getApp(config, args);
|
|
||||||
return app.fetch(req);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
|||||||
|
|
||||||
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
||||||
|
|
||||||
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
|
export type TanstackStartConfig<Env = TanstackStartEnv> =
|
||||||
|
FrameworkBkndConfig<Env>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bknd app instance
|
* Get bknd app instance
|
||||||
@@ -10,10 +11,10 @@ export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<En
|
|||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export async function getApp<Env = TanstackStartEnv>(
|
export async function getApp<Env = TanstackStartEnv>(
|
||||||
config: TanstackStartConfig<Env> = {},
|
config: TanstackStartConfig<Env> = {},
|
||||||
args: Env = process.env as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,11 +23,11 @@ export async function getApp<Env = TanstackStartEnv>(
|
|||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export function serve<Env = TanstackStartEnv>(
|
export function serve<Env = TanstackStartEnv>(
|
||||||
config: TanstackStartConfig<Env> = {},
|
config: TanstackStartConfig<Env> = {},
|
||||||
args: Env = process.env as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return async (request: Request) => {
|
return async (request: Request) => {
|
||||||
const app = await getApp(config, args);
|
const app = await getApp(config, args);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./web.adapter";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterAll, beforeAll, describe, test, expect } from "bun:test";
|
||||||
|
import { createBknd } from "./web.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("web adapter via createBknd", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: (options, args) => createBknd({ mode: "api", options }, args).getApp(),
|
||||||
|
makeHandler: (options, args) => createBknd({ mode: "api", options: options ?? {} }, args).serve(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------ MODE API ------------------------
|
||||||
|
test("caches app instance", async () => {
|
||||||
|
const bknd = createBknd({ mode: "api", options: { connection: { url: ":memory:" } } });
|
||||||
|
const app1 = await bknd.getApp();
|
||||||
|
const app2 = await bknd.getApp();
|
||||||
|
expect(app1).toBe(app2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getApi returns api", async () => {
|
||||||
|
const bknd = createBknd({ mode: "api", options: { connection: { url: ":memory:" } } });
|
||||||
|
const api = await bknd.getApi();
|
||||||
|
expect(api).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses createFrameworkApp ", async () => {
|
||||||
|
const bknd = createBknd({ mode: "api", options: { connection: { url: ":memory:" } } });
|
||||||
|
const app = await bknd.getApp();
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
expect(app.isBuilt()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serve returns a fetch handler", async () => {
|
||||||
|
const bknd = createBknd({ mode: "api", options: { connection: { url: ":memory:" } } });
|
||||||
|
const handler = bknd.serve();
|
||||||
|
const res = await handler(new Request("http://localhost:3000/api/system/config"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------ MODE STANDALONE ------------------------
|
||||||
|
describe("web adapter via createBknd in standalone mode", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: (options, args) => createBknd({ mode: "standalone", options }, args).getApp(),
|
||||||
|
makeHandler: (options, args) => createBknd({ mode: "standalone", options: options ?? {} }, args).serve(),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caches app instance", async () => {
|
||||||
|
const bknd = createBknd({ mode: "standalone", options: { connection: { url: ":memory:" } } });
|
||||||
|
const app1 = await bknd.getApp();
|
||||||
|
const app2 = await bknd.getApp();
|
||||||
|
expect(app1).toBe(app2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getApi returns api", async () => {
|
||||||
|
const bknd = createBknd({ mode: "standalone", options: { connection: { url: ":memory:" } } });
|
||||||
|
const api = await bknd.getApi();
|
||||||
|
expect(api).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses createRuntimeApp", async () => {
|
||||||
|
const bknd = createBknd({
|
||||||
|
mode: "standalone",
|
||||||
|
options: {
|
||||||
|
connection: { url: ":memory:" },
|
||||||
|
adminOptions: { adminBasepath: "/admin" },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const app = await bknd.getApp();
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
expect(app.isBuilt()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serve returns a fetch handler", async () => {
|
||||||
|
const bknd = createBknd({
|
||||||
|
mode: "standalone",
|
||||||
|
options: {
|
||||||
|
connection: { url: ":memory:" },
|
||||||
|
adminOptions: { adminBasepath: "/admin" },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const handler = bknd.serve();
|
||||||
|
const res = await handler(new Request("http://localhost:3000/api/system/config"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("check admin route", async () => {
|
||||||
|
const bknd = createBknd({
|
||||||
|
mode: "standalone",
|
||||||
|
options: {
|
||||||
|
connection: { url: ":memory:" },
|
||||||
|
adminOptions: { adminBasepath: "/admin" },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const handler = bknd.serve();
|
||||||
|
const res = await handler(new Request("http://localhost:3000/admin"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
createFrameworkApp,
|
||||||
|
createRuntimeApp,
|
||||||
|
type FrameworkBkndConfig,
|
||||||
|
type RuntimeBkndConfig,
|
||||||
|
} from "bknd/adapter";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
import type { App } from "App";
|
||||||
|
|
||||||
|
export type AdapterModeWithOptions<Env = Record<string, string | undefined>> =
|
||||||
|
| {
|
||||||
|
mode: "standalone";
|
||||||
|
options: RuntimeBkndConfig<Env>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "api";
|
||||||
|
options: FrameworkBkndConfig<Env>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBknd<Env>(config: AdapterModeWithOptions<Env>, env?: Env) {
|
||||||
|
let appPromise: Promise<App> | undefined;
|
||||||
|
|
||||||
|
const { mode, options } = config;
|
||||||
|
|
||||||
|
async function getApp(): Promise<App> {
|
||||||
|
if (!appPromise) {
|
||||||
|
if (mode === "standalone") {
|
||||||
|
if (options.adminOptions && !options.serveStatic) {
|
||||||
|
$console.warn(
|
||||||
|
"adminOptions provided without serveStatic — admin UI assets may not be served. " +
|
||||||
|
"See `serveStatic`, `serveStaticViaImport`, or add a `package.json` script that runs `bknd copy-assets --out {relative_static_assets_directory_path}`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
appPromise = createRuntimeApp(options, env);
|
||||||
|
} else {
|
||||||
|
appPromise = createFrameworkApp(options, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApi(opts?: { headers?: Headers; verify?: boolean }) {
|
||||||
|
const app = await getApp();
|
||||||
|
if (opts?.verify) {
|
||||||
|
const api = app.getApi({ headers: opts.headers });
|
||||||
|
await api.verifyAuth();
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
return app.getApi(opts?.headers ? { headers: opts.headers } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serve() {
|
||||||
|
return async (req: Request) => {
|
||||||
|
const app = await getApp();
|
||||||
|
return app.fetch(req);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getApp, getApi, serve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utility type to determine the config type based on mode,
|
||||||
|
* Usage `Config<"standalone">` or `Config<"api">`
|
||||||
|
*/
|
||||||
|
export type Config<T extends AdapterModeWithOptions["mode"]> = Extract<
|
||||||
|
Parameters<typeof createBknd>[0],
|
||||||
|
{ mode: T }
|
||||||
|
>['options'];
|
||||||
@@ -26,7 +26,9 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = (options?: { skip?: (string | RegExp)[] }) =>
|
export const auth = (options?: {
|
||||||
|
skip?: (string | RegExp)[];
|
||||||
|
}) =>
|
||||||
createMiddleware<ServerEnv>(async (c, next) => {
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
if (!c.get("auth")) {
|
if (!c.get("auth")) {
|
||||||
c.set("auth", {
|
c.set("auth", {
|
||||||
|
|||||||
@@ -4,48 +4,52 @@ import { plunkEmail } from "./plunk";
|
|||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("plunk", () => {
|
describe.skipIf(ALL_TESTS)("plunk", () => {
|
||||||
it("should throw on failed", async () => {
|
it("should throw on failed", async () => {
|
||||||
const driver = plunkEmail({ apiKey: "invalid" });
|
const driver = plunkEmail({ apiKey: "invalid" });
|
||||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should send an email", async () => {
|
it("should send an email", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined, // Default to what Plunk sets
|
from: undefined, // Default to what Plunk sets
|
||||||
});
|
});
|
||||||
const response = await driver.send(
|
const response = await driver.send(
|
||||||
"help@bknd.io",
|
"help@bknd.io",
|
||||||
"Test Email from Plunk",
|
"Test Email from Plunk",
|
||||||
"This is a test email",
|
"This is a test email",
|
||||||
);
|
);
|
||||||
expect(response).toBeDefined();
|
expect(response).toBeDefined();
|
||||||
expect(response.success).toBe(true);
|
expect(response.success).toBe(true);
|
||||||
expect(response.emails).toBeDefined();
|
expect(response.emails).toBeDefined();
|
||||||
expect(response.timestamp).toBeDefined();
|
expect(response.timestamp).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should send HTML email", async () => {
|
it("should send HTML email", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined,
|
from: undefined,
|
||||||
});
|
});
|
||||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
||||||
const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
|
const response = await driver.send(
|
||||||
expect(response).toBeDefined();
|
"help@bknd.io",
|
||||||
expect(response.success).toBe(true);
|
"HTML Test",
|
||||||
});
|
htmlBody,
|
||||||
|
);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should send with text and html", async () => {
|
it("should send with text and html", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined,
|
from: undefined,
|
||||||
});
|
});
|
||||||
const response = await driver.send("test@example.com", "Test Email", {
|
const response = await driver.send("test@example.com", "Test Email", {
|
||||||
text: "help@bknd.io",
|
text: "help@bknd.io",
|
||||||
html: "<p>This is HTML</p>",
|
html: "<p>This is HTML</p>",
|
||||||
});
|
});
|
||||||
expect(response).toBeDefined();
|
expect(response).toBeDefined();
|
||||||
expect(response.success).toBe(true);
|
expect(response.success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
import type { IEmailDriver } from "./index";
|
import type { IEmailDriver } from "./index";
|
||||||
|
|
||||||
export type PlunkEmailOptions = {
|
export type PlunkEmailOptions = {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlunkEmailSendOptions = {
|
export type PlunkEmailSendOptions = {
|
||||||
subscribed?: boolean;
|
subscribed?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
reply?: string;
|
reply?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlunkEmailResponse = {
|
export type PlunkEmailResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
emails: Array<{
|
emails: Array<{
|
||||||
contact: {
|
contact: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
email: string;
|
email: string;
|
||||||
}>;
|
}>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plunkEmail = (
|
export const plunkEmail = (
|
||||||
config: PlunkEmailOptions,
|
config: PlunkEmailOptions,
|
||||||
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
||||||
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
||||||
const from = config.from;
|
const from = config.from;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send: async (
|
send: async (
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
body: string | { text: string; html: string },
|
body: string | { text: string; html: string },
|
||||||
options?: PlunkEmailSendOptions,
|
options?: PlunkEmailSendOptions,
|
||||||
) => {
|
) => {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof body === "string") {
|
if (typeof body === "string") {
|
||||||
payload.body = body;
|
payload.body = body;
|
||||||
} else {
|
} else {
|
||||||
payload.body = body.html;
|
payload.body = body.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(host, {
|
const res = await fetch(host, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...payload, ...options }),
|
body: JSON.stringify({ ...payload, ...options }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Plunk API error: ${await res.text()}`);
|
throw new Error(`Plunk API error: ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await res.json()) as PlunkEmailResponse;
|
return (await res.json()) as PlunkEmailResponse;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { v4, v7, validate, version as uuidVersion } from "uuid";
|
|||||||
|
|
||||||
// generates v4
|
// generates v4
|
||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return v4();
|
return v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates v7
|
// generates v7
|
||||||
export function uuidv7(): string {
|
export function uuidv7(): string {
|
||||||
return v7();
|
return v7();
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate uuid
|
// validate uuid
|
||||||
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
|
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
|
||||||
return validate(uuid) && uuidVersion(uuid) === version;
|
return validate(uuid) && uuidVersion(uuid) === version;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
|
||||||
Kysely,
|
|
||||||
PostgresDialect,
|
|
||||||
type PostgresDialectConfig as KyselyPostgresDialectConfig,
|
|
||||||
} from "kysely";
|
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||||
import { customIntrospector } from "../Connection";
|
import { customIntrospector } from "../Connection";
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
|
export function postgresJs(
|
||||||
|
config: PostgresJSDialectConfig,
|
||||||
|
): PostgresJsConnection {
|
||||||
return new PostgresJsConnection(config);
|
return new PostgresJsConnection(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,10 +136,8 @@ export class Entity<
|
|||||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
|
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||||
field.isFillable(context),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getRequiredFields(): Field[] {
|
getRequiredFields(): Field[] {
|
||||||
@@ -191,18 +189,9 @@ export class Entity<
|
|||||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFields({
|
getFields(include_virtual: boolean = false): Field[] {
|
||||||
virtual = false,
|
if (include_virtual) return this.fields;
|
||||||
primary = true,
|
return this.fields.filter((f) => !f.isVirtual());
|
||||||
}: {
|
|
||||||
virtual?: boolean;
|
|
||||||
primary?: boolean;
|
|
||||||
} = {}): Field[] {
|
|
||||||
return this.fields.filter((f) => {
|
|
||||||
if (!virtual && f.isVirtual()) return false;
|
|
||||||
if (!primary && f instanceof PrimaryField) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addField(field: Field) {
|
addField(field: Field) {
|
||||||
@@ -242,7 +231,7 @@ export class Entity<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = this.getFillableFields(context as any, false);
|
const fields = this.getFillableFields(context, false);
|
||||||
|
|
||||||
if (options?.ignoreUnknown !== true) {
|
if (options?.ignoreUnknown !== true) {
|
||||||
const field_names = fields.map((f) => f.name);
|
const field_names = fields.map((f) => f.name);
|
||||||
@@ -286,7 +275,7 @@ export class Entity<
|
|||||||
fields = this.getFillableFields(options.context);
|
fields = this.getFillableFields(options.context);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
fields = this.getFields({ virtual: true });
|
fields = this.getFields(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
emgr?: EventManager<any>,
|
emgr?: EventManager<any>,
|
||||||
) {
|
) {
|
||||||
// add entities & relations
|
// add entities & relations
|
||||||
entities.forEach((entity) => void this.addEntity(entity));
|
entities.forEach((entity) => this.addEntity(entity));
|
||||||
relations.forEach((relation) => void this.addRelation(relation));
|
relations.forEach((relation) => this.addRelation(relation));
|
||||||
indices.forEach((index) => void this.addIndex(index));
|
indices.forEach((index) => this.addIndex(index));
|
||||||
|
|
||||||
if (!Connection.isConnection(connection)) {
|
if (!Connection.isConnection(connection)) {
|
||||||
throw new UnableToConnectException("");
|
throw new UnableToConnectException("");
|
||||||
|
|||||||
@@ -83,10 +83,8 @@ export class Mutator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// we should never get here, but just to be sure (why?)
|
// we should never get here, but just to be sure (why?)
|
||||||
if (!field.isFillable(context as any)) {
|
if (!field.isFillable(context)) {
|
||||||
throw new Error(
|
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||||
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// transform from field
|
// transform from field
|
||||||
|
|||||||
@@ -26,19 +26,11 @@ export const baseFieldConfigSchema = s
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
label: s.string(),
|
label: s.string(),
|
||||||
description: s.string(),
|
description: s.string(),
|
||||||
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
required: s.boolean({ default: false }),
|
||||||
fillable: s.anyOf(
|
fillable: s.anyOf([
|
||||||
[
|
s.boolean({ title: "Boolean" }),
|
||||||
s.boolean({ title: "Boolean" }),
|
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||||
s.array(s.string({ enum: ["create", "update"] }), {
|
]),
|
||||||
title: "Context",
|
|
||||||
uniqueItems: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
default: DEFAULT_FILLABLE,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
hidden: s.anyOf([
|
hidden: s.anyOf([
|
||||||
s.boolean({ title: "Boolean" }),
|
s.boolean({ title: "Boolean" }),
|
||||||
// @todo: tmp workaround
|
// @todo: tmp workaround
|
||||||
@@ -111,7 +103,7 @@ export abstract class Field<
|
|||||||
return this.config?.default_value;
|
return this.config?.default_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFillable(context?: "create" | "update"): boolean {
|
isFillable(context?: TActionContext): boolean {
|
||||||
if (Array.isArray(this.config.fillable)) {
|
if (Array.isArray(this.config.fillable)) {
|
||||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||||
}
|
}
|
||||||
@@ -173,7 +165,7 @@ export abstract class Field<
|
|||||||
// @todo: add field level validation
|
// @todo: add field level validation
|
||||||
isValid(value: any, context: TActionContext): boolean {
|
isValid(value: any, context: TActionContext): boolean {
|
||||||
if (typeof value !== "undefined") {
|
if (typeof value !== "undefined") {
|
||||||
return this.isFillable(context as any);
|
return this.isFillable(context);
|
||||||
} else if (context === "create") {
|
} else if (context === "create") {
|
||||||
return !this.isRequired();
|
return !this.isRequired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export function fieldTestSuite(
|
|||||||
const _config = {
|
const _config = {
|
||||||
..._requiredConfig,
|
..._requiredConfig,
|
||||||
required: false,
|
required: false,
|
||||||
fillable: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fieldJson(field: Field) {
|
function fieldJson(field: Field) {
|
||||||
@@ -117,7 +116,10 @@ export function fieldTestSuite(
|
|||||||
|
|
||||||
expect(fieldJson(fillable)).toEqual({
|
expect(fieldJson(fillable)).toEqual({
|
||||||
type: noConfigField.type,
|
type: noConfigField.type,
|
||||||
config: _config,
|
config: {
|
||||||
|
..._config,
|
||||||
|
fillable: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fieldJson(required)).toEqual({
|
expect(fieldJson(required)).toEqual({
|
||||||
@@ -148,6 +150,7 @@ export function fieldTestSuite(
|
|||||||
type: requiredAndDefault.type,
|
type: requiredAndDefault.type,
|
||||||
config: {
|
config: {
|
||||||
..._config,
|
..._config,
|
||||||
|
fillable: true,
|
||||||
required: true,
|
required: true,
|
||||||
default_value: config.defaultValue,
|
default_value: config.defaultValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export function getChangeSet(
|
|||||||
(acc, _value, key) => {
|
(acc, _value, key) => {
|
||||||
const field = fields.find((f) => f.name === key);
|
const field = fields.find((f) => f.name === key);
|
||||||
// @todo: filtering virtual here, need to check (because of media)
|
// @todo: filtering virtual here, need to check (because of media)
|
||||||
// @note: adding non-fillable here as it'll fail validaiton on backend
|
if (!field || field.isVirtual()) return;
|
||||||
if (!field || field.isVirtual() || !field.isFillable()) return;
|
|
||||||
const value = _value === "" ? null : _value;
|
const value = _value === "" ? null : _value;
|
||||||
|
|
||||||
// normalize to null if undefined
|
// normalize to null if undefined
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
RelationHelper,
|
RelationHelper,
|
||||||
} from "../relations";
|
} from "../relations";
|
||||||
import type { RepoQuery } from "../server/query";
|
import type { RepoQuery } from "../server/query";
|
||||||
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationType } from "./relation-types";
|
import type { RelationType } from "./relation-types";
|
||||||
|
|
||||||
const directions = ["source", "target"] as const;
|
const directions = ["source", "target"] as const;
|
||||||
export type TDirection = (typeof directions)[number];
|
export type TDirection = (typeof directions)[number];
|
||||||
@@ -35,7 +35,6 @@ export abstract class EntityRelation<
|
|||||||
mappedBy: s.string().optional(),
|
mappedBy: s.string().optional(),
|
||||||
inversedBy: s.string().optional(),
|
inversedBy: s.string().optional(),
|
||||||
required: s.boolean().optional(),
|
required: s.boolean().optional(),
|
||||||
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// don't make protected, App requires it to instantiatable
|
// don't make protected, App requires it to instantiatable
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
|||||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||||
import { type RelationType, RelationCascades, RelationTypes } from "./relation-types";
|
import { type RelationType, RelationTypes } from "./relation-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source entity receives the mapping field
|
* Source entity receives the mapping field
|
||||||
@@ -31,7 +31,6 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
|
|||||||
fieldConfig: s
|
fieldConfig: s
|
||||||
.object({
|
.object({
|
||||||
label: s.string(),
|
label: s.string(),
|
||||||
on_delete: s.string({ enum: RelationCascades }).optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
...EntityRelation.schema.properties,
|
...EntityRelation.schema.properties,
|
||||||
|
|||||||
@@ -4,22 +4,20 @@ import type { EntityRelation } from "./EntityRelation";
|
|||||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
|
||||||
|
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||||
|
|
||||||
export const relationFieldConfigSchema = s.strictObject({
|
export const relationFieldConfigSchema = s.strictObject({
|
||||||
reference: s.string(),
|
reference: s.string(),
|
||||||
target: s.string(), // @todo: potentially has to be an instance!
|
target: s.string(), // @todo: potentially has to be an instance!
|
||||||
target_field: s.string({ default: "id" }).optional(),
|
target_field: s.string({ default: "id" }).optional(),
|
||||||
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
|
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
|
||||||
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
|
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(),
|
||||||
...baseFieldConfigSchema.properties,
|
...baseFieldConfigSchema.properties,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
|
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
|
||||||
export type RelationFieldBaseConfig = {
|
export type RelationFieldBaseConfig = { label?: string };
|
||||||
label?: string;
|
|
||||||
on_delete?: RelationCascade;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class RelationField extends Field<RelationFieldConfig> {
|
export class RelationField extends Field<RelationFieldConfig> {
|
||||||
override readonly type = "relation";
|
override readonly type = "relation";
|
||||||
@@ -41,8 +39,6 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
return new RelationField(name, {
|
return new RelationField(name, {
|
||||||
...config,
|
...config,
|
||||||
required: relation.required,
|
required: relation.required,
|
||||||
// the relation owns the referential action, the field only materializes it
|
|
||||||
on_delete: config?.on_delete ?? relation.config.on_delete,
|
|
||||||
reference: target.reference,
|
reference: target.reference,
|
||||||
target: target.entity.name,
|
target: target.entity.name,
|
||||||
target_field: target.entity.getPrimaryField().name,
|
target_field: target.entity.getPrimaryField().name,
|
||||||
@@ -54,10 +50,6 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
return this.config.reference;
|
return this.config.reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDelete(): RelationCascade | undefined {
|
|
||||||
return this.config.on_delete;
|
|
||||||
}
|
|
||||||
|
|
||||||
target() {
|
target() {
|
||||||
return this.config.target;
|
return this.config.target;
|
||||||
}
|
}
|
||||||
@@ -71,7 +63,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
...super.schema()!,
|
...super.schema()!,
|
||||||
type: this.config.target_field_type ?? "integer",
|
type: this.config.target_field_type ?? "integer",
|
||||||
references: `${this.config.target}.${this.config.target_field}`,
|
references: `${this.config.target}.${this.config.target_field}`,
|
||||||
onDelete: this.config.on_delete ?? DEFAULT_RELATION_CASCADE,
|
onDelete: this.config.on_delete ?? "set null",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRela
|
|||||||
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
||||||
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
||||||
import { type RelationType, RelationTypes } from "./relation-types";
|
import { type RelationType, RelationTypes } from "./relation-types";
|
||||||
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
|
||||||
|
|
||||||
export * from "./EntityRelation";
|
export * from "./EntityRelation";
|
||||||
export * from "./EntityRelationAnchor";
|
export * from "./EntityRelationAnchor";
|
||||||
@@ -29,9 +28,6 @@ export {
|
|||||||
type PolymorphicRelationConfig,
|
type PolymorphicRelationConfig,
|
||||||
RelationTypes,
|
RelationTypes,
|
||||||
type RelationType,
|
type RelationType,
|
||||||
RelationCascades,
|
|
||||||
type RelationCascade,
|
|
||||||
DEFAULT_RELATION_CASCADE,
|
|
||||||
// field
|
// field
|
||||||
RelationField,
|
RelationField,
|
||||||
relationFieldConfigSchema,
|
relationFieldConfigSchema,
|
||||||
|
|||||||
@@ -5,13 +5,3 @@ export const RelationTypes = {
|
|||||||
Polymorphic: "poly",
|
Polymorphic: "poly",
|
||||||
} as const;
|
} as const;
|
||||||
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
||||||
|
|
||||||
export const RelationCascades = [
|
|
||||||
"cascade",
|
|
||||||
"set null",
|
|
||||||
"set default",
|
|
||||||
"restrict",
|
|
||||||
"no action",
|
|
||||||
] as const;
|
|
||||||
export type RelationCascade = (typeof RelationCascades)[number];
|
|
||||||
export const DEFAULT_RELATION_CASCADE: RelationCascade = "set null";
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class SchemaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||||
const fields = entity.getFields({ virtual: false });
|
const fields = entity.getFields(false);
|
||||||
const indices = this.em.getIndicesOf(entity);
|
const indices = this.em.getIndicesOf(entity);
|
||||||
|
|
||||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { transformObject } from "bknd/utils";
|
import { transformObject } from "bknd/utils";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import { set } from "lodash-es";
|
import { set } from "lodash-es";
|
||||||
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
|
||||||
|
|
||||||
export type MigrationContext = {
|
export type MigrationContext = {
|
||||||
db: Kysely<any>;
|
db: Kysely<any>;
|
||||||
@@ -108,29 +107,6 @@ export const migrations: Migration[] = [
|
|||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// change field.config.fillable to only "create" and "update"
|
|
||||||
version: 11,
|
|
||||||
up: async (config: InitialModuleConfigs) => {
|
|
||||||
const { data, ...rest } = config;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
entities: transformObject(data?.entities ?? {}, (entity) => {
|
|
||||||
return {
|
|
||||||
...entity,
|
|
||||||
fields: transformObject(entity?.fields ?? {}, (field) => {
|
|
||||||
const fillable = field!.config?.fillable;
|
|
||||||
if (!fillable || typeof fillable === "boolean") return field;
|
|
||||||
return { ...field, config: { ...field!.config, fillable: true } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||||
|
|||||||
@@ -1,858 +0,0 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { sort } from "./sort.plugin";
|
|
||||||
import { em, entity, text, number } from "bknd";
|
|
||||||
import { createApp } from "core/test/utils";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("sort plugin", () => {
|
|
||||||
test("should add sort field to configured entities", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const taskEntity = app.em.entity("tasks");
|
|
||||||
expect(taskEntity).toBeDefined();
|
|
||||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
|
||||||
expect(taskEntity?.field("position")?.type).toBe("number");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should auto-assign sort values on insert (starting from 0)", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// insert first item
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
expect(task1.position).toBe(0);
|
|
||||||
|
|
||||||
// insert second item
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
expect(task2.position).toBe(1);
|
|
||||||
|
|
||||||
// insert third item
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
expect(task3.position).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve manually set sort values on insert", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// insert with explicit position
|
|
||||||
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
|
|
||||||
expect(task.position).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reorder items via API endpoint", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks at positions 0, 1, 2
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
|
|
||||||
// move task3 (position 2) to position 0
|
|
||||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: task3.id, position: 0 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
// verify positions
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
|
||||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
|
||||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
|
||||||
|
|
||||||
expect(updatedTask3.position).toBe(0); // moved to position 0
|
|
||||||
expect(updatedTask1.position).toBe(1); // shifted down
|
|
||||||
expect(updatedTask2.position).toBe(2); // shifted down
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should automatically reorder when updating sort field directly", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks at positions 0, 1, 2, 3
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
|
||||||
|
|
||||||
// move task4 (position 3) to position 1 by updating directly
|
|
||||||
await mutator.updateOne(task4.id, { position: 1 });
|
|
||||||
|
|
||||||
// verify positions
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
|
||||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
|
||||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
|
||||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
|
||||||
|
|
||||||
expect(updatedTask1.position).toBe(0); // unchanged
|
|
||||||
expect(updatedTask2.position).toBe(2); // shifted down
|
|
||||||
expect(updatedTask3.position).toBe(3); // shifted down
|
|
||||||
expect(updatedTask4.position).toBe(1); // moved to position 1
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should automatically reorder when moving items up", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks at positions 0, 1, 2, 3
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
|
||||||
|
|
||||||
// move task1 (position 0) to position 2 by updating directly
|
|
||||||
await mutator.updateOne(task1.id, { position: 2 });
|
|
||||||
|
|
||||||
// verify positions
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
|
||||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
|
||||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
|
||||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
|
||||||
|
|
||||||
expect(updatedTask1.position).toBe(2); // moved to position 2
|
|
||||||
expect(updatedTask2.position).toBe(0); // shifted up
|
|
||||||
expect(updatedTask3.position).toBe(1); // shifted up
|
|
||||||
expect(updatedTask4.position).toBe(3); // unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should support scoped sorting", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
project_id: number(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
scope: "project_id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks in project 1
|
|
||||||
const { data: p1t1 } = await mutator.insertOne({
|
|
||||||
title: "P1 Task 1",
|
|
||||||
project_id: 1,
|
|
||||||
});
|
|
||||||
const { data: p1t2 } = await mutator.insertOne({
|
|
||||||
title: "P1 Task 2",
|
|
||||||
project_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// create tasks in project 2
|
|
||||||
const { data: p2t1 } = await mutator.insertOne({
|
|
||||||
title: "P2 Task 1",
|
|
||||||
project_id: 2,
|
|
||||||
});
|
|
||||||
const { data: p2t2 } = await mutator.insertOne({
|
|
||||||
title: "P2 Task 2",
|
|
||||||
project_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// positions should be scoped per project
|
|
||||||
expect(p1t1.position).toBe(0);
|
|
||||||
expect(p1t2.position).toBe(1);
|
|
||||||
expect(p2t1.position).toBe(0); // resets for new scope
|
|
||||||
expect(p2t2.position).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reorder only within scope", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
project_id: number(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
scope: "project_id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks in project 1
|
|
||||||
const { data: p1t1 } = await mutator.insertOne({
|
|
||||||
title: "P1 Task 1",
|
|
||||||
project_id: 1,
|
|
||||||
});
|
|
||||||
const { data: p1t2 } = await mutator.insertOne({
|
|
||||||
title: "P1 Task 2",
|
|
||||||
project_id: 1,
|
|
||||||
});
|
|
||||||
const { data: p1t3 } = await mutator.insertOne({
|
|
||||||
title: "P1 Task 3",
|
|
||||||
project_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// create tasks in project 2
|
|
||||||
const { data: p2t1 } = await mutator.insertOne({
|
|
||||||
title: "P2 Task 1",
|
|
||||||
project_id: 2,
|
|
||||||
});
|
|
||||||
const { data: p2t2 } = await mutator.insertOne({
|
|
||||||
title: "P2 Task 2",
|
|
||||||
project_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// move p1t3 to position 0 (should only affect project 1)
|
|
||||||
await app.server.request("/api/sort/tasks/reorder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: p1t3.id, position: 0 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// verify project 1 tasks
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
|
|
||||||
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
|
|
||||||
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
|
|
||||||
|
|
||||||
expect(updatedP1t3.position).toBe(0); // moved to position 0
|
|
||||||
expect(updatedP1t1.position).toBe(1); // shifted
|
|
||||||
expect(updatedP1t2.position).toBe(2); // shifted
|
|
||||||
|
|
||||||
// verify project 2 tasks are unchanged
|
|
||||||
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
|
|
||||||
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
|
|
||||||
|
|
||||||
expect(updatedP2t1.position).toBe(0); // unchanged
|
|
||||||
expect(updatedP2t2.position).toBe(1); // unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should recalculate all positions", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks with irregular positions
|
|
||||||
await mutator.insertOne({ title: "Task 1", position: 5 });
|
|
||||||
await mutator.insertOne({ title: "Task 2", position: 10 });
|
|
||||||
await mutator.insertOne({ title: "Task 3", position: 15 });
|
|
||||||
await mutator.insertOne({ title: "Task 4", position: 100 });
|
|
||||||
|
|
||||||
// recalculate
|
|
||||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
// verify positions are now 0, 1, 2, 3
|
|
||||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
orderBy: [{ position: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(tasks.length).toBe(4);
|
|
||||||
expect(tasks[0].position).toBe(0);
|
|
||||||
expect(tasks[1].position).toBe(1);
|
|
||||||
expect(tasks[2].position).toBe(2);
|
|
||||||
expect(tasks[3].position).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should recalculate positions within scope only", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
project_id: number(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
scope: "project_id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks in project 1 with irregular positions
|
|
||||||
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
|
|
||||||
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
|
|
||||||
|
|
||||||
// create tasks in project 2 with irregular positions
|
|
||||||
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
|
|
||||||
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
|
|
||||||
|
|
||||||
// recalculate only project 1
|
|
||||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ scope: 1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
// verify project 1 tasks are recalculated
|
|
||||||
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
where: { project_id: 1 },
|
|
||||||
orderBy: [{ position: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(p1Tasks.length).toBe(2);
|
|
||||||
expect(p1Tasks[0].position).toBe(0);
|
|
||||||
expect(p1Tasks[1].position).toBe(1);
|
|
||||||
|
|
||||||
// verify project 2 tasks are unchanged
|
|
||||||
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
where: { project_id: 2 },
|
|
||||||
orderBy: [{ position: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(p2Tasks.length).toBe(2);
|
|
||||||
expect(p2Tasks[0].position).toBe(10); // unchanged
|
|
||||||
expect(p2Tasks[1].position).toBe(20); // unchanged
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle moving items to the end", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks at positions 0, 1, 2, 3
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
|
||||||
|
|
||||||
// move task1 to the end (position 3)
|
|
||||||
await app.server.request("/api/sort/tasks/reorder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: task1.id, position: 3 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// verify positions
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
|
||||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
|
||||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
|
||||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
|
||||||
|
|
||||||
expect(updatedTask1.position).toBe(3); // moved to end
|
|
||||||
expect(updatedTask2.position).toBe(0); // shifted up
|
|
||||||
expect(updatedTask3.position).toBe(1); // shifted up
|
|
||||||
expect(updatedTask4.position).toBe(2); // shifted up
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 400 for invalid item id", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: 999999, position: 0 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle multiple entities with different configurations", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
categories: entity("categories", {
|
|
||||||
name: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
categories: {
|
|
||||||
field: "order",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
// verify both entities have their sort fields
|
|
||||||
const taskEntity = app.em.entity("tasks");
|
|
||||||
const categoryEntity = app.em.entity("categories");
|
|
||||||
|
|
||||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
|
||||||
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
|
|
||||||
|
|
||||||
// create items in both entities
|
|
||||||
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
|
|
||||||
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
|
|
||||||
|
|
||||||
expect(task.position).toBe(0);
|
|
||||||
expect(category.order).toBe(0);
|
|
||||||
|
|
||||||
// verify both endpoints exist
|
|
||||||
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const catRes = await app.server.request("/api/sort/categories/recalculate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(taskRes.status).toBe(200);
|
|
||||||
expect(catRes.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not trigger reorder when updating other fields", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
|
||||||
|
|
||||||
// update title only (should not trigger reordering)
|
|
||||||
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
|
|
||||||
|
|
||||||
// verify positions are unchanged
|
|
||||||
const repo = app.em.repo("tasks");
|
|
||||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
|
||||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
|
||||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
|
||||||
|
|
||||||
expect(updatedTask1.position).toBe(0);
|
|
||||||
expect(updatedTask2.position).toBe(1);
|
|
||||||
expect(updatedTask2.title).toBe("Task 2 Updated");
|
|
||||||
expect(updatedTask3.position).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle null sort values", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create task with null position (bypass listener by using kysely directly)
|
|
||||||
await app.connection.kysely
|
|
||||||
.insertInto("tasks")
|
|
||||||
.values({ title: "Task with null", position: null })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// create normal task
|
|
||||||
await mutator.insertOne({ title: "Task 2" });
|
|
||||||
|
|
||||||
// update the null task to have a position
|
|
||||||
const nullTask = await app.connection.kysely
|
|
||||||
.selectFrom("tasks")
|
|
||||||
.selectAll()
|
|
||||||
.where("title", "=", "Task with null")
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
await mutator.updateOne(nullTask!.id, { position: 0 });
|
|
||||||
|
|
||||||
// verify both tasks have proper positions
|
|
||||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
sort: { by: "position", dir: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(tasks.length).toBe(2);
|
|
||||||
expect(tasks[0].position).toBe(0);
|
|
||||||
expect(tasks[1].position).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not create duplicates when moving to an occupied position", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create two tasks at positions 0 and 1
|
|
||||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
|
||||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
|
||||||
|
|
||||||
expect(task1.position).toBe(0);
|
|
||||||
expect(task2.position).toBe(1);
|
|
||||||
|
|
||||||
// move task2 (at position 1) to position 0
|
|
||||||
await mutator.updateOne(task2.id, { position: 0 });
|
|
||||||
|
|
||||||
// verify no duplicates
|
|
||||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
sort: { by: "position", dir: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(tasks.length).toBe(2);
|
|
||||||
expect(tasks[0].id).toBe(task2.id);
|
|
||||||
expect(tasks[0].position).toBe(0);
|
|
||||||
expect(tasks[1].id).toBe(task1.id);
|
|
||||||
expect(tasks[1].position).toBe(1);
|
|
||||||
|
|
||||||
// verify no tasks have the same position
|
|
||||||
const positions = tasks.map((t) => t.position);
|
|
||||||
const uniquePositions = new Set(positions);
|
|
||||||
expect(uniquePositions.size).toBe(positions.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve order when recalculating", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
data: em({
|
|
||||||
tasks: entity("tasks", {
|
|
||||||
title: text(),
|
|
||||||
}),
|
|
||||||
}).toJSON(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const mutator = app.em.mutator("tasks");
|
|
||||||
|
|
||||||
// create tasks with specific positions
|
|
||||||
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
|
|
||||||
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
|
|
||||||
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
|
|
||||||
|
|
||||||
// recalculate
|
|
||||||
await app.server.request("/api/sort/tasks/recalculate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// verify order is preserved (B, A, C based on original positions)
|
|
||||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
|
||||||
sort: { by: "position", dir: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
|
|
||||||
expect(tasks[0].position).toBe(0);
|
|
||||||
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
|
|
||||||
expect(tasks[1].position).toBe(1);
|
|
||||||
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
|
|
||||||
expect(tasks[2].position).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
|
|
||||||
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { sql } from "kysely";
|
|
||||||
|
|
||||||
const DEFAULT_BATCH_SIZE = 1000;
|
|
||||||
|
|
||||||
export type SortPluginOptions = {
|
|
||||||
/**
|
|
||||||
* The base path for the API endpoints.
|
|
||||||
* @default "/api/sort"
|
|
||||||
*/
|
|
||||||
apiBasePath?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for entities that should have sorting enabled.
|
|
||||||
* Key is the entity name, value is the configuration.
|
|
||||||
*/
|
|
||||||
entities: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name of the sort property (must be a number field).
|
|
||||||
*/
|
|
||||||
field: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional scope field name. If provided, sorting will only happen within the same scope.
|
|
||||||
* For example, if scope is "category_id", items will only be sorted within items
|
|
||||||
* that have the same category_id value.
|
|
||||||
*/
|
|
||||||
scope?: string;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The batch size for recalculating sort order.
|
|
||||||
* @default 1000
|
|
||||||
*/
|
|
||||||
recalculateBatchSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class SortError extends Exception {
|
|
||||||
override name = "SortError";
|
|
||||||
override code = HttpStatus.BAD_REQUEST;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sort({
|
|
||||||
apiBasePath = "/api/sort",
|
|
||||||
entities,
|
|
||||||
recalculateBatchSize = DEFAULT_BATCH_SIZE,
|
|
||||||
}: SortPluginOptions): AppPlugin {
|
|
||||||
return (app: App) => {
|
|
||||||
return {
|
|
||||||
name: "sort",
|
|
||||||
schema: () => {
|
|
||||||
return em(
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(entities).map(([entityName, config]) => [
|
|
||||||
entityName,
|
|
||||||
entity(entityName, {
|
|
||||||
[config.field]: number({ default_value: 0 }),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
({ index }, schema) => {
|
|
||||||
for (const [entityName, config] of Object.entries(entities)) {
|
|
||||||
const indexed = app.em.getIndexedFields(entityName);
|
|
||||||
if (!indexed.some((f) => f.name === config.field)) {
|
|
||||||
index(schema[entityName]!).on([config.field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onBuilt: async () => {
|
|
||||||
invariant(
|
|
||||||
entities && Object.keys(entities).length > 0,
|
|
||||||
"At least one entity must be configured",
|
|
||||||
);
|
|
||||||
|
|
||||||
// validate entities exist and have the configured fields
|
|
||||||
for (const [entityName, config] of Object.entries(entities)) {
|
|
||||||
const entity = app.em.entity(entityName);
|
|
||||||
invariant(entity, `Entity "${entityName}" not found in schema`);
|
|
||||||
|
|
||||||
const sortFieldSchema = entity.field(config.field)!;
|
|
||||||
invariant(
|
|
||||||
sortFieldSchema,
|
|
||||||
`Sort field "${config.field}" not found in entity "${entityName}"`,
|
|
||||||
);
|
|
||||||
invariant(
|
|
||||||
sortFieldSchema.type === "number",
|
|
||||||
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (config.scope) {
|
|
||||||
const scopeFieldSchema = entity.field(config.scope);
|
|
||||||
invariant(
|
|
||||||
scopeFieldSchema,
|
|
||||||
`Scope field "${config.scope}" not found in entity "${entityName}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hono = new Hono();
|
|
||||||
|
|
||||||
// register recalculate endpoints for each entity
|
|
||||||
for (const [entityName, config] of Object.entries(entities)) {
|
|
||||||
hono.post(
|
|
||||||
`/${entityName}/recalculate`,
|
|
||||||
jsc(
|
|
||||||
"json",
|
|
||||||
s
|
|
||||||
.object({
|
|
||||||
scope: s.any().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
const scope = body?.scope;
|
|
||||||
|
|
||||||
await recalculateSortOrder(
|
|
||||||
app,
|
|
||||||
entityName,
|
|
||||||
config,
|
|
||||||
recalculateBatchSize,
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ success: true, message: "Sort order recalculated" });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
hono.post(
|
|
||||||
`/${entityName}/reorder`,
|
|
||||||
jsc(
|
|
||||||
"json",
|
|
||||||
s.object({
|
|
||||||
id: s.any(),
|
|
||||||
position: s.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { id, position } = c.req.valid("json");
|
|
||||||
|
|
||||||
await reorderItem(app, entityName, config, id, position);
|
|
||||||
|
|
||||||
return c.json({ success: true, message: "Item reordered" });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.server.route(apiBasePath, hono);
|
|
||||||
|
|
||||||
// register listeners for automatic reordering
|
|
||||||
registerListeners(app, entities);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reorderItem(
|
|
||||||
app: App,
|
|
||||||
entityName: string,
|
|
||||||
config: SortPluginOptions["entities"][string],
|
|
||||||
id: any,
|
|
||||||
newPosition: number,
|
|
||||||
) {
|
|
||||||
const { field: sortField, scope: scopeField } = config;
|
|
||||||
const em = app.em.fork();
|
|
||||||
const kysely = em.connection.kysely;
|
|
||||||
|
|
||||||
// get the item
|
|
||||||
const { data: item } = await em.repo(entityName).findOne({ id });
|
|
||||||
if (!item) {
|
|
||||||
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldPosition = item[sortField] as number | null | undefined;
|
|
||||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
|
||||||
|
|
||||||
// update the item with new position (using forked em, so no listeners are triggered)
|
|
||||||
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
|
|
||||||
|
|
||||||
// shift other items using kysely
|
|
||||||
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
|
|
||||||
if (newPosition < oldPosition) {
|
|
||||||
// moving up: increment items between newPosition and oldPosition
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
|
||||||
.where(sortField as any, ">=", newPosition)
|
|
||||||
.where(sortField as any, "<", oldPosition)
|
|
||||||
.where("id", "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
} else {
|
|
||||||
// moving down: decrement items between oldPosition and newPosition
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
|
||||||
.where(sortField as any, ">", oldPosition)
|
|
||||||
.where(sortField as any, "<=", newPosition)
|
|
||||||
.where("id", "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
}
|
|
||||||
} else if (oldPosition === undefined || oldPosition === null) {
|
|
||||||
// new item, shift everything at or after this position
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
|
||||||
.where(sortField as any, ">=", newPosition)
|
|
||||||
.where("id", "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recalculateSortOrder(
|
|
||||||
app: App,
|
|
||||||
entityName: string,
|
|
||||||
config: SortPluginOptions["entities"][string],
|
|
||||||
batchSize: number,
|
|
||||||
scope?: any,
|
|
||||||
) {
|
|
||||||
const { field: sortField, scope: scopeField } = config;
|
|
||||||
const db = app.connection.kysely;
|
|
||||||
|
|
||||||
const { count } = (await db
|
|
||||||
.selectFrom(entityName)
|
|
||||||
.select((eb) => eb.fn.count<number>("id").as("count"))
|
|
||||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
|
||||||
eb.where(scopeField as any, "=", scope),
|
|
||||||
)
|
|
||||||
.$castTo<{ count: number }>()
|
|
||||||
.executeTakeFirst()) ?? { count: 0 };
|
|
||||||
|
|
||||||
const batches = Math.ceil(count / batchSize);
|
|
||||||
for (let i = 0; i < batches; i++) {
|
|
||||||
// get all items in scope, ordered by current sort value
|
|
||||||
const items = await db
|
|
||||||
.selectFrom(entityName)
|
|
||||||
.select(["id", sortField])
|
|
||||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
|
||||||
eb.where(scopeField as any, "=", scope),
|
|
||||||
)
|
|
||||||
.orderBy(sortField, "asc")
|
|
||||||
.limit(batchSize)
|
|
||||||
.offset(i * batchSize)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const newQbs = items.map((item, index) =>
|
|
||||||
db
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: index })
|
|
||||||
.where("id", "=", item.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.connection.executeQueries(...newQbs);
|
|
||||||
$console.log(
|
|
||||||
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
|
|
||||||
const kysely = app.connection.kysely;
|
|
||||||
|
|
||||||
// handle insert events
|
|
||||||
app.emgr.onEvent(
|
|
||||||
DatabaseEvents.MutatorInsertBefore,
|
|
||||||
async (e) => {
|
|
||||||
const entityName = e.params.entity.name;
|
|
||||||
const config = entities[entityName];
|
|
||||||
if (!config) return e.params.data;
|
|
||||||
|
|
||||||
const { field: sortField, scope: scopeField } = config;
|
|
||||||
const data = e.params.data;
|
|
||||||
const scopeValue = scopeField ? data[scopeField] : undefined;
|
|
||||||
|
|
||||||
// if no position provided, set to max + 1
|
|
||||||
if (data[sortField] === undefined || data[sortField] === null) {
|
|
||||||
const query = kysely
|
|
||||||
.selectFrom(entityName)
|
|
||||||
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
|
|
||||||
// add scope filter if needed
|
|
||||||
.$if(Boolean(scopeField && scopeValue), (eb) =>
|
|
||||||
eb.where(scopeField as any, "=", scopeValue as any),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await query.executeTakeFirst();
|
|
||||||
const max = result?.max ?? -1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
[sortField]: max + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// if position is provided, shift other items at or after that position
|
|
||||||
const newPosition = data[sortField] as number;
|
|
||||||
|
|
||||||
let shiftQuery = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
|
||||||
.where(sortField as any, ">=", newPosition);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await shiftQuery.execute();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "sync",
|
|
||||||
id: "bknd-sort-insert",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// handle update events
|
|
||||||
app.emgr.onEvent(
|
|
||||||
DatabaseEvents.MutatorUpdateBefore,
|
|
||||||
async (e) => {
|
|
||||||
const entityName = e.params.entity.name;
|
|
||||||
const config = entities[entityName];
|
|
||||||
if (!config) return e.params.data;
|
|
||||||
|
|
||||||
const { field: sortField, scope: scopeField } = config;
|
|
||||||
const data = e.params.data;
|
|
||||||
|
|
||||||
// only handle if sort field is being updated
|
|
||||||
if (!(sortField in data)) return e.params.data;
|
|
||||||
|
|
||||||
const newPosition = data[sortField] as number;
|
|
||||||
const id = e.params.entityId;
|
|
||||||
|
|
||||||
// get the current item to know its old position and scope
|
|
||||||
const item = await kysely
|
|
||||||
.selectFrom(entityName)
|
|
||||||
.selectAll()
|
|
||||||
.where("id" as any, "=", id)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!item) return data;
|
|
||||||
|
|
||||||
const oldPosition = item[sortField] as number | null | undefined;
|
|
||||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
|
||||||
|
|
||||||
// if oldPosition is null or undefined, treat as inserting at newPosition
|
|
||||||
if (oldPosition === null || oldPosition === undefined) {
|
|
||||||
// shift items at or after the new position
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
|
||||||
.where(sortField as any, ">=", newPosition)
|
|
||||||
.where("id" as any, "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// shift other items using kysely
|
|
||||||
if (oldPosition !== newPosition) {
|
|
||||||
if (newPosition < oldPosition) {
|
|
||||||
// moving up: increment items between newPosition and oldPosition
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
|
||||||
.where(sortField as any, ">=", newPosition)
|
|
||||||
.where(sortField as any, "<", oldPosition)
|
|
||||||
.where("id" as any, "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
} else {
|
|
||||||
// moving down: decrement items between oldPosition and newPosition
|
|
||||||
let query = kysely
|
|
||||||
.updateTable(entityName)
|
|
||||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
|
||||||
.where(sortField as any, ">", oldPosition)
|
|
||||||
.where(sortField as any, "<=", newPosition)
|
|
||||||
.where("id" as any, "!=", id);
|
|
||||||
|
|
||||||
if (scopeField && scopeValue !== undefined) {
|
|
||||||
query = query.where(scopeField as any, "=", scopeValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "sync",
|
|
||||||
id: "bknd-sort-update",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,4 +9,3 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
|||||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||||
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
||||||
export { sort, type SortPluginOptions } from "./data/sort.plugin";
|
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ export type BkndAdminConfig = {
|
|||||||
* @default `/`
|
* @default `/`
|
||||||
*/
|
*/
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
/**
|
|
||||||
* Sub-path for the Admin UI within the base path
|
|
||||||
* @default ``
|
|
||||||
*/
|
|
||||||
admin_basepath?: string;
|
|
||||||
/**
|
/**
|
||||||
* Path to return to when clicking the logo
|
* Path to return to when clicking the logo
|
||||||
* @default `/`
|
* @default `/`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export {
|
|||||||
type ClientProviderProps,
|
type ClientProviderProps,
|
||||||
useApi,
|
useApi,
|
||||||
useBaseUrl,
|
useBaseUrl,
|
||||||
useClientContext,
|
useClientContext
|
||||||
} from "./ClientProvider";
|
} from "./ClientProvider";
|
||||||
|
|
||||||
export * from "./api/use-api";
|
export * from "./api/use-api";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class AppReduced {
|
|||||||
protected appJson: AppType,
|
protected appJson: AppType,
|
||||||
protected _options: BkndAdminProps["config"] = {},
|
protected _options: BkndAdminProps["config"] = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||||
return constructEntity(name, entity);
|
return constructEntity(name, entity);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ export function Logo({
|
|||||||
scale = 0.2,
|
scale = 0.2,
|
||||||
fill,
|
fill,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: { scale?: number; fill?: string; theme?: string }) {
|
||||||
scale?: number;
|
|
||||||
fill?: string;
|
|
||||||
theme?: string;
|
|
||||||
}) {
|
|
||||||
const t = useTheme();
|
const t = useTheme();
|
||||||
const theme = props.theme ?? t.theme;
|
const theme = props.theme ?? t.theme;
|
||||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const Group = <E extends ElementType = "div">({
|
|||||||
{...props}
|
{...props}
|
||||||
data-role="group"
|
data-role="group"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
|
"flex flex-col gap-1.5 w-full",
|
||||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||||
as === "fieldset" && error && "border-red-500",
|
as === "fieldset" && error && "border-red-500",
|
||||||
error && "text-red-500",
|
error && "text-red-500",
|
||||||
@@ -97,7 +97,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
|
||||||
disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed",
|
disabledOrReadonly && "bg-muted/50 text-primary/50",
|
||||||
!disabledOrReadonly &&
|
!disabledOrReadonly &&
|
||||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||||
props.className,
|
props.className,
|
||||||
@@ -154,7 +154,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -214,7 +214,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
|||||||
{...props}
|
{...props}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed scale-150 ml-1"
|
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={handleCheck}
|
onChange={handleCheck}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
@@ -260,6 +260,7 @@ export const Switch = forwardRef<
|
|||||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onCheckedChange={(bool) => {
|
onCheckedChange={(bool) => {
|
||||||
|
console.log("setting", bool);
|
||||||
props.onChange?.({ target: { value: bool } });
|
props.onChange?.({ target: { value: bool } });
|
||||||
}}
|
}}
|
||||||
{...(props as any)}
|
{...(props as any)}
|
||||||
@@ -293,7 +294,7 @@ export const Select = forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
|
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
|
||||||
"appearance-none h-11 w-full",
|
"appearance-none h-11 w-full",
|
||||||
!props.multiple && "border-r-8 border-r-transparent",
|
!props.multiple && "border-r-8 border-r-transparent",
|
||||||
props.className,
|
props.className,
|
||||||
|
|||||||
@@ -117,11 +117,7 @@ const ArrayAdd = ({
|
|||||||
schema,
|
schema,
|
||||||
path: _path,
|
path: _path,
|
||||||
label = "Add",
|
label = "Add",
|
||||||
}: {
|
}: { schema: JsonSchema; path: string; label?: string }) => {
|
||||||
schema: JsonSchema;
|
|
||||||
path: string;
|
|
||||||
label?: string;
|
|
||||||
}) => {
|
|
||||||
const {
|
const {
|
||||||
setValue,
|
setValue,
|
||||||
value: { currentIndex },
|
value: { currentIndex },
|
||||||
|
|||||||
@@ -64,11 +64,6 @@ export function Step({
|
|||||||
path = [],
|
path = [],
|
||||||
id,
|
id,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
|
||||||
children: React.ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
id: string;
|
|
||||||
path?: string[];
|
|
||||||
}) {
|
|
||||||
return <div {...rest}>{children}</div>;
|
return <div {...rest}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
|
||||||
import { useEvent } from "../../hooks/use-event";
|
import { useEvent } from "../../hooks/use-event";
|
||||||
import { useBkndOptions } from "ui/client/BkndProvider";
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Transforms `path` into its relative `base` version
|
* Transforms `path` into its relative `base` version
|
||||||
@@ -71,13 +70,11 @@ export function Link({
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const bkndOptions = useBkndOptions();
|
|
||||||
const adminBasePath = bkndOptions?.admin_basepath ?? "";
|
|
||||||
const _href = props.href ?? props.to;
|
|
||||||
|
|
||||||
|
const _href = props.href ?? props.to;
|
||||||
const href = router
|
const href = router
|
||||||
.hrefs(
|
.hrefs(
|
||||||
_href[0] === "~" ? adminBasePath + _href.slice(1) : router.base + _href,
|
_href[0] === "~" ? _href.slice(1) : router.base + _href,
|
||||||
router, // pass router as a second argument for convinience
|
router, // pass router as a second argument for convinience
|
||||||
)
|
)
|
||||||
.replace("//", "/");
|
.replace("//", "/");
|
||||||
@@ -85,9 +82,8 @@ export function Link({
|
|||||||
const active =
|
const active =
|
||||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||||
|
|
||||||
// console.log({ adminBasePath, _href, href, bkndOptions })
|
|
||||||
if (native) {
|
if (native) {
|
||||||
return <a className={`${active ? "active " : ""}${className}`} {...props} href={href} />;
|
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wouterOnClick = (e: any) => {
|
const wouterOnClick = (e: any) => {
|
||||||
@@ -103,11 +99,10 @@ export function Link({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @ts-expect-error className is not typed on WouterLink
|
|
||||||
<WouterLink
|
<WouterLink
|
||||||
{...props}
|
// @ts-expect-error className is not typed on WouterLink
|
||||||
className={`${active ? "active " : ""}${className}`}
|
className={`${active ? "active " : ""}${className}`}
|
||||||
href={`~${href}`}
|
{...props}
|
||||||
onClick={wouterOnClick}
|
onClick={wouterOnClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,12 +35,7 @@ export function checkMaxReached({
|
|||||||
current = 0,
|
current = 0,
|
||||||
overwrite,
|
overwrite,
|
||||||
added,
|
added,
|
||||||
}: {
|
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
|
||||||
maxItems?: number;
|
|
||||||
current?: number;
|
|
||||||
overwrite?: boolean;
|
|
||||||
added: number;
|
|
||||||
}) {
|
|
||||||
if (!maxItems) {
|
if (!maxItems) {
|
||||||
return {
|
return {
|
||||||
reject: false,
|
reject: false,
|
||||||
|
|||||||
@@ -101,12 +101,7 @@ export function useNavigate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _url = options?.absolute
|
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
|
|
||||||
/\/+/g,
|
|
||||||
"/",
|
|
||||||
)
|
|
||||||
: url;
|
|
||||||
const state = {
|
const state = {
|
||||||
...options?.state,
|
...options?.state,
|
||||||
referrer: location,
|
referrer: location,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function EntityForm({
|
|||||||
className,
|
className,
|
||||||
action,
|
action,
|
||||||
}: EntityFormProps) {
|
}: EntityFormProps) {
|
||||||
const fields = entity.getFields({ virtual: true, primary: false });
|
const fields = entity.getFillableFields(action, true);
|
||||||
const options = useEntityAdminOptions(entity, action);
|
const options = useEntityAdminOptions(entity, action);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +93,10 @@ export function EntityForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!field.isFillable(action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const _key = `${entity.name}-${field.name}-${key}`;
|
const _key = `${entity.name}-${field.name}-${key}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +128,7 @@ export function EntityForm({
|
|||||||
<EntityFormField
|
<EntityFormField
|
||||||
field={field}
|
field={field}
|
||||||
fieldApi={props}
|
fieldApi={props}
|
||||||
disabled={fieldsDisabled || !field.isFillable(action)}
|
disabled={fieldsDisabled}
|
||||||
tabIndex={key + 1}
|
tabIndex={key + 1}
|
||||||
action={action}
|
action={action}
|
||||||
data={data}
|
data={data}
|
||||||
@@ -283,10 +287,7 @@ function EntityJsonFormField({
|
|||||||
fieldApi,
|
fieldApi,
|
||||||
field,
|
field,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||||
fieldApi: TFieldApi;
|
|
||||||
field: JsonField;
|
|
||||||
}) {
|
|
||||||
const [error, setError] = useState<any>(null);
|
const [error, setError] = useState<any>(null);
|
||||||
const handleUpdate = useEvent((value: any) => {
|
const handleUpdate = useEvent((value: any) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -315,10 +316,7 @@ function EntityEnumFormField({
|
|||||||
fieldApi,
|
fieldApi,
|
||||||
field,
|
field,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: { fieldApi: TFieldApi; field: EnumField }) {
|
||||||
fieldApi: TFieldApi;
|
|
||||||
field: EnumField;
|
|
||||||
}) {
|
|
||||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
fieldApi.handleChange(e.target.value);
|
fieldApi.handleChange(e.target.value);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Switch, TextInput } from "@mantine/core";
|
import { Switch, TextInput } from "@mantine/core";
|
||||||
import { IconDatabase } from "@tabler/icons-react";
|
import { IconDatabase } from "@tabler/icons-react";
|
||||||
import {
|
import { ManyToOneRelation, type RelationType, RelationTypes } from "data/relations";
|
||||||
DEFAULT_RELATION_CASCADE,
|
|
||||||
ManyToOneRelation,
|
|
||||||
type RelationType,
|
|
||||||
RelationCascades,
|
|
||||||
RelationTypes,
|
|
||||||
} from "data/relations";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
||||||
import { TbRefresh } from "react-icons/tb";
|
import { TbRefresh } from "react-icons/tb";
|
||||||
@@ -52,9 +46,7 @@ const schema = s.strictObject({
|
|||||||
type: s.string({ enum: Relations.map((r) => r.type) }),
|
type: s.string({ enum: Relations.map((r) => r.type) }),
|
||||||
source: stringIdentifier,
|
source: stringIdentifier,
|
||||||
target: stringIdentifier,
|
target: stringIdentifier,
|
||||||
config: s.object({
|
config: s.object({}),
|
||||||
on_delete: s.string({ enum: RelationCascades }).optional(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type ComponentCtx<T extends FieldValues = FieldValues> = {
|
type ComponentCtx<T extends FieldValues = FieldValues> = {
|
||||||
@@ -194,25 +186,6 @@ const Callout = ({ children }: { children: ReactNode }) => (
|
|||||||
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
|
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function OnDelete({
|
|
||||||
control,
|
|
||||||
label = "On target delete",
|
|
||||||
}: {
|
|
||||||
control: Control<any>;
|
|
||||||
label?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<MantineSelect
|
|
||||||
control={control}
|
|
||||||
name="config.on_delete"
|
|
||||||
label={label}
|
|
||||||
allowDeselect={false}
|
|
||||||
defaultValue={DEFAULT_RELATION_CASCADE}
|
|
||||||
data={RelationCascades.map((cascade) => ({ value: cascade, label: cascade }))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
|
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -236,7 +209,6 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
|||||||
control={control}
|
control={control}
|
||||||
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
||||||
/>
|
/>
|
||||||
<OnDelete control={control} />
|
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -250,32 +222,30 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
|||||||
</div>
|
</div>
|
||||||
{source && target && config && (
|
{source && target && config && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<>
|
||||||
<IconDatabase className="size-4" />
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
{source}.{config.mappedBy || target}_id {"→"} {target}
|
<IconDatabase className="size-4" />
|
||||||
</pre>
|
{source}.{config.mappedBy || target}_id {"→"} {target}
|
||||||
<p>
|
</pre>
|
||||||
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
|
|
||||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
|
||||||
<i>{config.required ? "required" : "optional"}</i>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
|
||||||
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
|
||||||
</p>
|
|
||||||
{config.sourceCardinality ? (
|
|
||||||
<p>
|
<p>
|
||||||
<Pre>{target}</Pre> should not have more than{" "}
|
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
|
||||||
<Pre>{config.sourceCardinality}</Pre> referencing entr
|
|
||||||
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<p>
|
||||||
|
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
|
||||||
|
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||||
|
<i>{config.required ? "required" : "optional"}</i>.
|
||||||
|
</p>
|
||||||
|
{config.sourceCardinality ? (
|
||||||
|
<p>
|
||||||
|
<Pre>{target}</Pre> should not have more than{" "}
|
||||||
|
<Pre>{config.sourceCardinality}</Pre> referencing entr
|
||||||
|
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -288,7 +258,7 @@ function OneToOne({
|
|||||||
data: {
|
data: {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
config: { mappedBy, required, on_delete },
|
config: { mappedBy, required },
|
||||||
},
|
},
|
||||||
}: ComponentCtx) {
|
}: ComponentCtx) {
|
||||||
return (
|
return (
|
||||||
@@ -301,7 +271,6 @@ function OneToOne({
|
|||||||
placeholder={target}
|
placeholder={target}
|
||||||
/>
|
/>
|
||||||
<Switch label="Required" {...register("config.required")} />
|
<Switch label="Required" {...register("config.required")} />
|
||||||
<OnDelete control={control} />
|
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -314,26 +283,24 @@ function OneToOne({
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<>
|
||||||
<IconDatabase className="size-4" />
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
{source}.{mappedBy || target}_id {"↔"} {target}
|
<IconDatabase className="size-4" />
|
||||||
</pre>
|
{source}.{mappedBy || target}_id {"↔"} {target}
|
||||||
<p>
|
</pre>
|
||||||
A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
|
<p>
|
||||||
.
|
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
|
||||||
</p>
|
<Pre>{target}</Pre>.
|
||||||
<p>
|
</p>
|
||||||
A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
|
<p>
|
||||||
(which references <Pre>{target}</Pre>).
|
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
|
||||||
</p>
|
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||||
<p>
|
</p>
|
||||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
<p>
|
||||||
<i>{required ? "required" : "optional"}</i>.
|
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||||
</p>
|
<i>{required ? "required" : "optional"}</i>.
|
||||||
<p>
|
</p>
|
||||||
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
</>
|
||||||
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -367,7 +334,6 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
|||||||
{...register("config.connectionTableMappedName")}
|
{...register("config.connectionTableMappedName")}
|
||||||
placeholder={table}
|
placeholder={table}
|
||||||
/>
|
/>
|
||||||
<OnDelete control={control} label="On delete (both references)" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/*<TextInput
|
{/*<TextInput
|
||||||
@@ -379,20 +345,18 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<>
|
||||||
<IconDatabase className="size-4" />
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
{source} {"→"} {table} {"←"} {target}
|
<IconDatabase className="size-4" />
|
||||||
</pre>
|
{source} {"→"} {table} {"←"} {target}
|
||||||
<p>
|
</pre>
|
||||||
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
<p>
|
||||||
</p>
|
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||||
<p>
|
</p>
|
||||||
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
<p>
|
||||||
</p>
|
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
||||||
<p>
|
</p>
|
||||||
If an entry of <Pre>{source}</Pre> or <Pre>{target}</Pre> is deleted, the
|
</>
|
||||||
connection is <i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -427,24 +391,26 @@ function Polymorphic({ register, control, data: { type, source, target, config }
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<>
|
||||||
<IconDatabase className="size-4" />
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
{source} {"←"} {target}
|
<IconDatabase className="size-4" />
|
||||||
</pre>
|
{source} {"←"} {target}
|
||||||
<p>
|
</pre>
|
||||||
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
|
|
||||||
<Pre>entity_id</Pre> to make the (polymorphic) reference.
|
|
||||||
</p>
|
|
||||||
{config.targetCardinality ? (
|
|
||||||
<p>
|
<p>
|
||||||
<Pre>{source}</Pre> should not have more than{" "}
|
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||||
<Pre>{config.targetCardinality}</Pre> reference
|
|
||||||
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<p>
|
||||||
|
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
|
||||||
|
<Pre>entity_id</Pre> to make the (polymorphic) reference.
|
||||||
|
</p>
|
||||||
|
{config.targetCardinality ? (
|
||||||
|
<p>
|
||||||
|
<Pre>{source}</Pre> should not have more than{" "}
|
||||||
|
<Pre>{config.targetCardinality}</Pre> reference
|
||||||
|
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function useEntityForm({
|
|||||||
}: EntityFormProps) {
|
}: EntityFormProps) {
|
||||||
const data = initialData ?? {};
|
const data = initialData ?? {};
|
||||||
// @todo: check if virtual must be filtered
|
// @todo: check if virtual must be filtered
|
||||||
const fields = entity.getFields({ virtual: true, primary: false });
|
const fields = entity.getFillableFields(action, true);
|
||||||
|
|
||||||
// filter defaultValues to only contain fillable fields
|
// filter defaultValues to only contain fillable fields
|
||||||
const defaultValues = getDefaultValues(fields, data);
|
const defaultValues = getDefaultValues(fields, data);
|
||||||
|
|||||||
@@ -110,12 +110,7 @@ const EntityLinkList = ({
|
|||||||
title,
|
title,
|
||||||
context,
|
context,
|
||||||
suggestCreate = false,
|
suggestCreate = false,
|
||||||
}: {
|
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
|
||||||
entities: Entity[];
|
|
||||||
title?: string;
|
|
||||||
context: "data" | "schema";
|
|
||||||
suggestCreate?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
const { readonly } = useBknd();
|
const { readonly } = useBknd();
|
||||||
const navigate = useRouteNavigate();
|
const navigate = useRouteNavigate();
|
||||||
@@ -200,11 +195,7 @@ const EntityContextMenu = ({
|
|||||||
entity,
|
entity,
|
||||||
children,
|
children,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: {
|
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
|
||||||
entity: Entity;
|
|
||||||
children: DropdownClickableChild;
|
|
||||||
enabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!enabled) return children;
|
if (!enabled) return children;
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
|
|||||||
@@ -66,10 +66,7 @@ const relationAnyOfValues = {
|
|||||||
export const DataSettings = ({
|
export const DataSettings = ({
|
||||||
schema,
|
schema,
|
||||||
config,
|
config,
|
||||||
}: {
|
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
|
||||||
schema: ModuleSchemas["data"];
|
|
||||||
config: ModuleConfigs["data"];
|
|
||||||
}) => {
|
|
||||||
const { app, readonly } = useBknd();
|
const { app, readonly } = useBknd();
|
||||||
const prefix = app.getAbsolutePath("settings");
|
const prefix = app.getAbsolutePath("settings");
|
||||||
const entities = Object.keys(config.entities ?? {});
|
const entities = Object.keys(config.entities ?? {});
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ function CustomUserAvatarDropzone() {
|
|||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
actions: { openFileInput },
|
actions: { openFileInput },
|
||||||
} = Media.useDropzone();
|
} = Media.useDropzone();
|
||||||
const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
|
const {
|
||||||
|
isOver,
|
||||||
|
isOverAccepted,
|
||||||
|
files: [file] = [],
|
||||||
|
} = Media.useDropzoneState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ input[disabled]::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flex-animate {
|
.flex-animate {
|
||||||
transition:
|
transition: flex-grow 0.2s ease, background-color 0.2s ease;
|
||||||
flex-grow 0.2s ease,
|
|
||||||
background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
.flex-initial {
|
.flex-initial {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
declare module "*.css";
|
declare module '*.css';
|
||||||
@@ -335,55 +335,3 @@ export default {
|
|||||||
```
|
```
|
||||||
|
|
||||||
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />
|
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />
|
||||||
|
|
||||||
|
|
||||||
### `sort`
|
|
||||||
|
|
||||||
A plugin that maintains sequential numeric positions for one or more entities. Positions are updated automatically when items are inserted or moved. You can optionally configure a scope so that positions are maintained independently within groups, such as tasks within a project.
|
|
||||||
|
|
||||||
```typescript title="bknd.config.ts"
|
|
||||||
import { sort } from "bknd/plugins";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
sort({
|
|
||||||
entities: {
|
|
||||||
tasks: {
|
|
||||||
field: "position",
|
|
||||||
scope: "project_id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} satisfies BkndConfig;
|
|
||||||
```
|
|
||||||
|
|
||||||
The configured `field` is used for the numeric position. If the field does not exist, the plugin adds it as a number field with a default value of `0`; if it already exists, it must be a number field. The plugin also adds an index for the field when one is not already present. The optional `scope` field limits reordering to records with the same scope value.
|
|
||||||
|
|
||||||
The plugin registers the following endpoints. The default base path is `/api/sort` and can be changed with `apiBasePath`:
|
|
||||||
|
|
||||||
- `POST /api/sort/:entity/reorder` moves an item to a position. The request body must include the item `id` and its new numeric `position`.
|
|
||||||
- `POST /api/sort/:entity/recalculate` recalculates positions in ascending order of the configured field. To recalculate only one scope, pass its value in the optional `scope` property.
|
|
||||||
|
|
||||||
```http title="Move a task"
|
|
||||||
POST /api/sort/tasks/reorder
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": "task_123",
|
|
||||||
"position": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```http title="Recalculate a project's task positions"
|
|
||||||
POST /api/sort/tasks/recalculate
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"scope": "project_456"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<AutoTypeTable path="../app/src/plugins/data/sort.plugin.ts" name="SortPluginOptions" />
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"astro",
|
"astro",
|
||||||
"sveltekit",
|
"sveltekit",
|
||||||
"tanstack-start",
|
"tanstack-start",
|
||||||
|
"web",
|
||||||
"vite",
|
"vite",
|
||||||
"nuxt",
|
"nuxt"
|
||||||
"solid-start"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
|||||||
|
|
||||||
## Serve the API
|
## Serve the API
|
||||||
|
|
||||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
|
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||||
|
|
||||||
```ts title="src/bknd.ts"
|
```ts title="src/bknd.ts"
|
||||||
import {
|
import {
|
||||||
@@ -127,80 +127,31 @@ export const DELETE = handler;
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Enabling the Admin UI
|
## Enabling the Admin UI
|
||||||
Create a file at `admin/[[...admin]]/page.client.tsx`:
|
|
||||||
|
|
||||||
``` tsx title="admin/[[...admin]]/page.client.tsx"
|
|
||||||
"use client";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
export const Admin = dynamic(async () => (await import("bknd/ui")).Admin, {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
<Callout type="info">
|
|
||||||
We are using [Colocation](https://nextjs.org/docs/app/getting-started/project-structure#colocation) to prevent server-side hydration error, and the dynamic function to load Admin component only on client side.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
|
|
||||||
Create a page at `admin/[[...admin]]/page.tsx`:
|
Create a page at `admin/[[...admin]]/page.tsx`:
|
||||||
|
|
||||||
```tsx title="admin/[[...admin]]/page.tsx"
|
```tsx title="admin/[[...admin]]/page.tsx"
|
||||||
|
import { Admin } from "bknd/ui";
|
||||||
import { getApi } from "@/bknd";
|
import { getApi } from "@/bknd";
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
import { Admin } from "./page.client";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
// make sure to verify auth using headers
|
// make sure to verify auth using headers
|
||||||
const api = await getApi({ verify: true });
|
const api = await getApi({ verify: true });
|
||||||
|
|
||||||
// early return if not logged in
|
|
||||||
if (!api.getUser()) {
|
|
||||||
return redirect("/unauthorized");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Admin
|
||||||
<Admin
|
withProvider={{ user: api.getUser() }}
|
||||||
withProvider={{ user: api.getUser() }}
|
config={{
|
||||||
config={{
|
basepath: "/admin",
|
||||||
basepath: "/admin",
|
logo_return_path: "/../",
|
||||||
logo_return_path: "/../",
|
theme: "system",
|
||||||
theme: "system",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## React SDK configuration
|
|
||||||
|
|
||||||
To use queries and mutation on client side, bknd provides first class support for it with it's [React SDK](/usage/react)
|
|
||||||
to set it up with Next.js add the `ClientProvider` to your root layout
|
|
||||||
|
|
||||||
|
|
||||||
```tsx title="app/layout.tsx"
|
|
||||||
import { ClientProvider } from "bknd/client";
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body>
|
|
||||||
<ClientProvider>
|
|
||||||
{children}
|
|
||||||
</ClientProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Example usage of the API
|
## Example usage of the API
|
||||||
|
|
||||||
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
||||||
@@ -221,26 +172,3 @@ export default async function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Note for Production
|
|
||||||
If your admin component is not rendering and has a error like `Identifier 'b' has already been declared`, you will need to add these lines to your `next.config.(mjs|ts)` file:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
// ...
|
|
||||||
// for turbopack
|
|
||||||
experimental: {
|
|
||||||
turbopackMinify: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// for webpack
|
|
||||||
// webpack: (config) => {
|
|
||||||
// config.optimization.minimize = false;
|
|
||||||
// return config;
|
|
||||||
// },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Solid Start"
|
|
||||||
description: "Run bknd inside Solid Start"
|
|
||||||
tags: ["documentation"]
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
To get started with Solid Start and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter.
|
|
||||||
|
|
||||||
Create a new Solid Start project by following the [official guide](https://docs.solidjs.com/solid-start/getting-started), and then install bknd as a dependency:
|
|
||||||
|
|
||||||
<Tabs groupId='package-manager' persist items={[ 'npm', 'pnpm', 'yarn', 'bun']}>
|
|
||||||
|
|
||||||
```bash tab="npm"
|
|
||||||
npm install bknd
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash tab="pnpm"
|
|
||||||
pnpm install bknd
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash tab="yarn"
|
|
||||||
yarn add bknd
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash tab="bun"
|
|
||||||
bun add bknd
|
|
||||||
```
|
|
||||||
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
<Callout type="warning">
|
|
||||||
When run with Node.js, a version of 22 (LTS) or higher is required. Please
|
|
||||||
verify your version by running `node -v`, and
|
|
||||||
[upgrade](https://nodejs.org/en/download/) if necessary.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
Now create a `bknd.config.ts` file in the root of your project. If you created the project using the CLI starter, this file is already created for you.
|
|
||||||
|
|
||||||
```typescript title="bknd.config.ts"
|
|
||||||
import type { SolidStartBkndConfig } from "bknd/adapter/solid-start";
|
|
||||||
import { em, entity, text, boolean } from "bknd";
|
|
||||||
import { secureRandomString } from "bknd/utils";
|
|
||||||
|
|
||||||
const schema = em({
|
|
||||||
todos: entity("todos", {
|
|
||||||
title: text(),
|
|
||||||
done: boolean(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// register your schema to get automatic type completion
|
|
||||||
type Database = (typeof schema)["DB"];
|
|
||||||
declare module "bknd" {
|
|
||||||
interface DB extends Database {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
connection: {
|
|
||||||
url: "file:data.db",
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
// the seed option is only executed if the database was empty
|
|
||||||
seed: async (ctx) => {
|
|
||||||
// create some entries
|
|
||||||
await ctx.em.mutator("todos").insertMany([
|
|
||||||
{ title: "Learn bknd", done: true },
|
|
||||||
{ title: "Build something cool", done: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// and create a user
|
|
||||||
await ctx.app.module.auth.createUser({
|
|
||||||
email: "test@bknd.io",
|
|
||||||
password: "12345678",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
data: schema.toJSON(),
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: secureRandomString(32),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// please run `bun bknd copy-assets --out public/admin` to copy the admin assets into the public directory
|
|
||||||
// alternatively add this as a postinstall script in your package.json like this:
|
|
||||||
// "postinstall": "bknd copy-assets --out public/admin"
|
|
||||||
adminOptions: {
|
|
||||||
adminBasepath: "/admin",
|
|
||||||
assetsPath: "/admin/", // trailing slash is important
|
|
||||||
},
|
|
||||||
} satisfies SolidStartBkndConfig;
|
|
||||||
```
|
|
||||||
|
|
||||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `SolidStartBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Serve the API and Admin UI
|
|
||||||
|
|
||||||
The Solid Start adapter uses middleware to handle API requests and serve the Admin UI. Create a `src/middleware/index.ts` file:
|
|
||||||
|
|
||||||
```ts title="src/middleware/index.ts"
|
|
||||||
import { createMiddleware } from "@solidjs/start/middleware";
|
|
||||||
import config from "../../bknd.config";
|
|
||||||
import { serve } from "bknd/adapter/solid-start";
|
|
||||||
|
|
||||||
const handler = serve(config);
|
|
||||||
|
|
||||||
export default createMiddleware({
|
|
||||||
onRequest: async (event) => {
|
|
||||||
const url = new URL(event.request.url);
|
|
||||||
const pathname = url.pathname;
|
|
||||||
|
|
||||||
if (pathname.startsWith("/api") || pathname !== "/") {
|
|
||||||
const res = await handler(event.request);
|
|
||||||
|
|
||||||
if (res && res.status !== 404) {
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update the app config (Solid Start v1 based on vinxi)
|
|
||||||
|
|
||||||
Add the following to your `app.config.ts` file:
|
|
||||||
|
|
||||||
```ts title="app.config.ts"
|
|
||||||
import { defineConfig } from "@solidjs/start/config";
|
|
||||||
|
|
||||||
const app = defineConfig({
|
|
||||||
// ... your existing config
|
|
||||||
middleware: "src/middleware/index.ts",
|
|
||||||
// ... your existing config
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update the vite config (Solid Start v2)
|
|
||||||
|
|
||||||
Add the following to your `vite.config.ts` file:
|
|
||||||
|
|
||||||
```ts title="vite.config.ts"
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import solid from "vite-plugin-solid";
|
|
||||||
import { solidStart } from "@solidjs/start/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
solid(),
|
|
||||||
solidStart({
|
|
||||||
middleware: "./src/middleware/index.ts",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
<Callout type="success">
|
|
||||||
You can visit https://localhost:3000/admin to see the admin UI. Additionally you can create more todos as you explore the admin UI.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
|
|
||||||
|
|
||||||
|
|
||||||
```ts title="src/lib/bknd.ts"
|
|
||||||
import { getApp as getBkndApp } from "bknd/adapter/solid-start";
|
|
||||||
import bkndConfig from "../../bknd.config";
|
|
||||||
import type { App } from "bknd";
|
|
||||||
|
|
||||||
export const getApp = async () => {
|
|
||||||
return await getBkndApp(bkndConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getApi({
|
|
||||||
headers,
|
|
||||||
verify,
|
|
||||||
}: {
|
|
||||||
verify?: boolean;
|
|
||||||
headers?: Headers;
|
|
||||||
}) {
|
|
||||||
const app = await getApp();
|
|
||||||
|
|
||||||
if (verify) {
|
|
||||||
const api = app.getApi({ headers });
|
|
||||||
await api.verifyAuth();
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.getApi();
|
|
||||||
};
|
|
||||||
```
|
|
||||||
## Example usage of the API
|
|
||||||
|
|
||||||
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
|
||||||
|
|
||||||
```tsx title="src/routes/index.tsx"
|
|
||||||
import { getApi } from "~/lib/bknd";
|
|
||||||
import { query, createAsync } from "@solidjs/router";
|
|
||||||
|
|
||||||
export const getTodo = async () => {
|
|
||||||
"use server"
|
|
||||||
const api = await getApi({});
|
|
||||||
const limit = 5;
|
|
||||||
const todos = await api.data.readMany("todos");
|
|
||||||
const total = todos.body.meta.total as number;
|
|
||||||
return { total, todos: todos as unknown as Todo[], limit };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTodosFromServer = query(async () => await getTodo(), "getTodosFromServer");
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const data = createAsync(() => getTodosFromServer());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
<For each={data()}>
|
|
||||||
{(todo) => <li>{todo.title}</li>}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using authentication
|
|
||||||
|
|
||||||
When using authentication, you'll need to pass the headers to the `getApi` function to verify the user is authenticated.
|
|
||||||
|
|
||||||
```tsx title="src/routes/auth.tsx"
|
|
||||||
import { createResource, Suspense } from "solid-js";
|
|
||||||
import { A } from "@solidjs/router";
|
|
||||||
import { getRequestEvent } from "solid-js/web";
|
|
||||||
import { getApi } from "~/lib/bknd";
|
|
||||||
|
|
||||||
export const getUser = async () => {
|
|
||||||
"use server"
|
|
||||||
const request = getRequestEvent()?.request;
|
|
||||||
const api = await getApi({ verify: true, headers: request?.headers });
|
|
||||||
return api.getUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Auth() {
|
|
||||||
const [data] = createResource(async () => {
|
|
||||||
const user = await getUser()
|
|
||||||
return { user };
|
|
||||||
}, {
|
|
||||||
initialValue: {
|
|
||||||
user: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
|
||||||
<main class="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
|
||||||
<div class="flex flex-row items-center ">
|
|
||||||
<img
|
|
||||||
class="dark:invert size-18"
|
|
||||||
src="/solid.svg"
|
|
||||||
alt="Solid logo"
|
|
||||||
/>
|
|
||||||
<div class="ml-3.5 mr-2 opacity-70">&</div>
|
|
||||||
<img
|
|
||||||
class="dark:invert"
|
|
||||||
src="/bknd.svg"
|
|
||||||
alt="bknd logo"
|
|
||||||
width={183}
|
|
||||||
height={59}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<p>Loading...</p>}>
|
|
||||||
<div>
|
|
||||||
{data()?.user ? (
|
|
||||||
<>
|
|
||||||
Logged in as {data()?.user?.email}.
|
|
||||||
<A
|
|
||||||
class="underline"
|
|
||||||
target="_self"
|
|
||||||
href={"/api/auth/logout"}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</A>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p>
|
|
||||||
Not logged in.
|
|
||||||
<A
|
|
||||||
class="underline"
|
|
||||||
target="_self"
|
|
||||||
href={"/admin/auth/login"}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</A>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs opacity-50">
|
|
||||||
Sign in with:
|
|
||||||
<b>
|
|
||||||
<code>test@bknd.io</code>
|
|
||||||
</b>
|
|
||||||
/
|
|
||||||
<b>
|
|
||||||
<code>12345678</code>
|
|
||||||
</b>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
Use `target="_self"` on links, anytime your traversing to an external route (like `/api/auth/logout` which are handled by bknd's middleware) to prevent solid router from intercepting the link.
|
|
||||||
```jsx
|
|
||||||
<A href="/api/auth/logout" target="_self">
|
|
||||||
Logout
|
|
||||||
</A>
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
title: "Web Adapter"
|
||||||
|
description: "Bring bknd to any web framework or runtime"
|
||||||
|
tags: ["documentation"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is the Web Adapter?
|
||||||
|
|
||||||
|
The web adapter (`bknd/adapter/web`) is a framework/runtime agnostic adapter which can integrate `bknd` into **any** web framework or runtime — even ones without a dedicated adapter.
|
||||||
|
|
||||||
|
**Use the web adapter when:**
|
||||||
|
- Your framework doesn't have a dedicated bknd adapter
|
||||||
|
- You want full control over your server setup
|
||||||
|
- You're building a custom server or edge function
|
||||||
|
|
||||||
|
**Use a platform-specific adapter when:**
|
||||||
|
- You're using a supported framework (Next.js, SvelteKit, Nuxt, Astro, etc.)
|
||||||
|
- You prefer opinionated, zero-config setup
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<Tabs groupId='package-manager' persist items={[ 'npm', 'pnpm', 'yarn', 'bun']}>
|
||||||
|
|
||||||
|
```bash tab="npm"
|
||||||
|
npm install bknd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash tab="pnpm"
|
||||||
|
pnpm install bknd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash tab="yarn"
|
||||||
|
yarn add bknd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash tab="bun"
|
||||||
|
bun add bknd
|
||||||
|
```
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
Start by creating a config file:
|
||||||
|
|
||||||
|
```typescript title="bknd.config.ts"
|
||||||
|
import type { Config } from "bknd/adapter/web";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connection: {
|
||||||
|
url: "file:data.db",
|
||||||
|
},
|
||||||
|
} satisfies Config<"api">;
|
||||||
|
```
|
||||||
|
<Callout type="info" title="The Config type helper">
|
||||||
|
The `Config` type is a helper type which maps the correct config type based on mode.
|
||||||
|
|
||||||
|
Use `Config<"api">` when you are using a React-based framework otherwise use `Config<"standalone">` to serve both the Admin UI and bknd api using the same handler
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
### Helper Singleton
|
||||||
|
```typescript title="lib/bknd.ts"
|
||||||
|
import { createBknd } from "bknd/adapter/web";
|
||||||
|
import config from "../bknd.config";
|
||||||
|
|
||||||
|
export const bknd = createBknd({ mode: "standalone", options: config }, process.env);
|
||||||
|
// ^ use "api" if using a React-based framework
|
||||||
|
```
|
||||||
|
|
||||||
|
`createBknd` returns three methods:
|
||||||
|
|
||||||
|
| Method | Returns | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `getApp(env: Env)` | `Promise<App>` | The built bknd app instance |
|
||||||
|
| `getApi({ headers, verify })` | `Promise<Api>` | Convenience wrapper around `app.getApi()` |
|
||||||
|
| `serve()` | `(req: Request) => Promise<Response>` | A fetch handler for your server |
|
||||||
|
|
||||||
|
## Choosing an Admin UI Path
|
||||||
|
|
||||||
|
The web adapter supports two approaches to serving the admin UI:
|
||||||
|
|
||||||
|
| Path | Best For | How |
|
||||||
|
|------|----------|-----|
|
||||||
|
| **Client-side** (default) | React frameworks: Next.js, Astro, React Router, Tanstack Start, Waku | Import and render `<Admin />` in your own React route |
|
||||||
|
| **Server-side** | Non-React frameworks: SvelteKit, Nuxt, Bun, Node, Deno | The server serves the Admin UI assets and API |
|
||||||
|
|
||||||
|
## Path 1: Client-Side Admin (Default)
|
||||||
|
|
||||||
|
For React-capable frameworks, render the admin UI directly in a route:
|
||||||
|
|
||||||
|
```tsx title="app/admin/[[...admin]]/page.tsx"
|
||||||
|
import { Admin } from "bknd/ui";
|
||||||
|
import "bknd/dist/styles.css";
|
||||||
|
import { bknd } from "@/bknd";
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const api = await bknd.getApi({ verify: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Admin
|
||||||
|
withProvider={{ user: api.getUser() }}
|
||||||
|
config={{ basepath: "/admin" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No server-side admin configuration needed. The admin UI runs entirely in the browser.
|
||||||
|
|
||||||
|
## Path 2: Server-Side Admin
|
||||||
|
|
||||||
|
For non-React frameworks or standalone servers, enable the admin controller:
|
||||||
|
|
||||||
|
```typescript title="src/bknd.ts"
|
||||||
|
import { createBknd } from "bknd/adapter/web";
|
||||||
|
import config from "../bknd.config";
|
||||||
|
|
||||||
|
export const bknd = createBknd({ mode: "standalone", options: config }, env);
|
||||||
|
// ^ use "api" if using a React-based framework
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll also need to serve the admin's static assets (JS, CSS). Choose one of three strategies:
|
||||||
|
|
||||||
|
### Strategy 1: serveStatic Middleware
|
||||||
|
|
||||||
|
**Best for:** Bun, Node, standalone servers with filesystem access
|
||||||
|
|
||||||
|
Use Hono's platform-specific `serveStatic` in `bknd.config.ts` to serve assets from `node_modules/bknd/dist/static/`:
|
||||||
|
|
||||||
|
```typescript title="bknd.config.ts"
|
||||||
|
import type { Config } from "bknd/adapter/web";
|
||||||
|
import { serveStatic } from "hono/bun"; // or "@hono/node-server/serve-static"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connection: {
|
||||||
|
url: "file:data.db",
|
||||||
|
serveStatic: serveStatic({ root: "./node_modules/bknd/dist/static" }),
|
||||||
|
adminOptions: {
|
||||||
|
adminBasepath: "/admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Config<"standalone">;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strategy 2: copy-assets Postinstall
|
||||||
|
|
||||||
|
**Best for:** SvelteKit, Nuxt, any framework with a static directory
|
||||||
|
|
||||||
|
Copy assets at install time and let your framework serve them:
|
||||||
|
|
||||||
|
```json title="package.json"
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "bknd copy-assets --out static"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-framework output paths:
|
||||||
|
|
||||||
|
| Framework | `--out` flag |
|
||||||
|
|-----------|-------------|
|
||||||
|
| SvelteKit | `--out static` |
|
||||||
|
| Nuxt | `--out public` |
|
||||||
|
| Next.js | `--out public` |
|
||||||
|
| Astro | `--out public` |
|
||||||
|
| Qwik | `--out public` |
|
||||||
|
|
||||||
|
### Strategy 3: serveStaticViaImport
|
||||||
|
|
||||||
|
**Best for:** Edge/serverless runtimes (Deno Deploy, Cloudflare Workers)
|
||||||
|
|
||||||
|
For environments without filesystem access:
|
||||||
|
|
||||||
|
```typescript title="bknd.config.ts"
|
||||||
|
import type { Config } from "bknd/adapter/web";
|
||||||
|
import { serveStaticViaImport } from "bknd/adapter";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connection: {
|
||||||
|
url: "file:data.db",
|
||||||
|
serveStatic: serveStaticViaImport(),
|
||||||
|
adminOptions: {
|
||||||
|
adminBasepath: "/admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Config<"standalone">;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serving Requests
|
||||||
|
|
||||||
|
Example use `bknd.serve()` as a fetch handler in your server:
|
||||||
|
|
||||||
|
```typescript title="server.ts"
|
||||||
|
import { bknd } from "./bknd";
|
||||||
|
|
||||||
|
// Bun
|
||||||
|
Bun.serve({ fetch: bknd.serve(), port: 3000 });
|
||||||
|
|
||||||
|
// Node (with @hono/node-server)
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
serve({ fetch: bknd.serve(), port: 3000 });
|
||||||
|
|
||||||
|
// Next.js
|
||||||
|
const handler = bknd.serve(); // here you'll use "api" for mode when setting up `bknd` instance
|
||||||
|
|
||||||
|
export const GET = handler;
|
||||||
|
export const POST = handler;
|
||||||
|
export const PUT = handler;
|
||||||
|
export const PATCH = handler;
|
||||||
|
export const DELETE = handler;
|
||||||
|
|
||||||
|
// Qwik City (Middleware)
|
||||||
|
export const onRequest: RequestHandler = async ({
|
||||||
|
url,
|
||||||
|
next,
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
getWritableStream,
|
||||||
|
}) => {
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api") || pathname !== "/") {
|
||||||
|
const response = await handler(request);
|
||||||
|
|
||||||
|
// skips unknown paths
|
||||||
|
if (response.status === 404) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// adds the set-cookie header
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// for redirect
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (location) {
|
||||||
|
throw redirect(response.status as any, location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream back the body
|
||||||
|
status(response.status);
|
||||||
|
if (response.body) {
|
||||||
|
await response.body?.pipeTo(getWritableStream());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `bknd.getApp()` to integrate with an existing Hono or other router setup.
|
||||||
|
|
||||||
|
## Reference: Config\<T\>
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type AdapterModeWithOptions<Env = Record<string, string | undefined>> =
|
||||||
|
| {
|
||||||
|
mode: "standalone";
|
||||||
|
options: RuntimeBkndConfig<Env>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "api";
|
||||||
|
options: FrameworkBkndConfig<Env>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Config<T extends AdapterModeWithOptions["mode"]> = Extract<
|
||||||
|
Parameters<typeof createBknd>[0],
|
||||||
|
{ mode: T }
|
||||||
|
>['options'];
|
||||||
|
```
|
||||||
@@ -45,10 +45,10 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
|
|||||||
href="/integration/nuxt"
|
href="/integration/nuxt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
icon={<Icon icon="simple-icons:solid" className="text-fd-primary !size-6" />}
|
icon={<Icon icon="tabler:world" className="text-fd-primary !size-6" />}
|
||||||
title="Solid Start"
|
title="Web Adapter"
|
||||||
href="/integration/solid-start"
|
href="/integration/web"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
|
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
|
||||||
|
|||||||
@@ -162,18 +162,18 @@ Pick your framework or runtime to get started.
|
|||||||
href="/integration/nuxt"
|
href="/integration/nuxt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card
|
|
||||||
icon={<Icon icon="simple-icons:solid" className="text-fd-primary !size-6" />}
|
|
||||||
title="Solid Start"
|
|
||||||
href="/integration/solid-start"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
|
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
|
||||||
title="AWS Lambda"
|
title="AWS Lambda"
|
||||||
href="/integration/aws"
|
href="/integration/aws"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
icon={<Icon icon="tabler:world" className="text-fd-primary !size-6" />}
|
||||||
|
title="Web Adapter"
|
||||||
|
href="/integration/web"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
icon={<Icon icon="simple-icons:vite" className="text-fd-primary !size-6" />}
|
icon={<Icon icon="simple-icons:vite" className="text-fd-primary !size-6" />}
|
||||||
title="Vite"
|
title="Vite"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Build
|
||||||
|
/dist
|
||||||
|
/lib
|
||||||
|
/lib-types
|
||||||
|
/server
|
||||||
|
|
||||||
|
# Development
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.mf
|
||||||
|
.rollup.cache
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/releases
|
||||||
|
data.db
|
||||||
|
public/admin
|
||||||
|
tmp
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
|
*.
|
||||||
|
.vscode/settings.json
|
||||||
|
.history
|
||||||
|
.yarn
|
||||||
|
bazel-*
|
||||||
|
bazel-bin
|
||||||
|
bazel-out
|
||||||
|
bazel-qwik
|
||||||
|
bazel-testlogs
|
||||||
|
dist
|
||||||
|
dist-dev
|
||||||
|
lib
|
||||||
|
lib-types
|
||||||
|
etc
|
||||||
|
external
|
||||||
|
node_modules
|
||||||
|
temp
|
||||||
|
tsc-out
|
||||||
|
tsdoc-metadata.json
|
||||||
|
target
|
||||||
|
output
|
||||||
|
rollup.config.js
|
||||||
|
build
|
||||||
|
.cache
|
||||||
|
.vscode
|
||||||
|
.rollup.cache
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
vite.config.ts
|
||||||
|
*.spec.tsx
|
||||||
|
*.spec.ts
|
||||||
|
.netlify
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
server
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "chrome",
|
||||||
|
"url": "http://localhost:5173",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"name": "dev.debug",
|
||||||
|
"request": "launch",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"program": "${workspaceFolder}/node_modules/vite/bin/vite.js",
|
||||||
|
"args": ["--mode", "ssr", "--force"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"onRequest": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "qonRequest",
|
||||||
|
"description": "onRequest function for a route index",
|
||||||
|
"body": [
|
||||||
|
"export const onRequest: RequestHandler = (request) => {",
|
||||||
|
" $0",
|
||||||
|
"};",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"loader$": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "qloader$",
|
||||||
|
"description": "loader$()",
|
||||||
|
"body": ["export const $1 = routeLoader$(() => {", " $0", "});"],
|
||||||
|
},
|
||||||
|
"action$": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "qaction$",
|
||||||
|
"description": "action$()",
|
||||||
|
"body": ["export const $1 = routeAction$((data) => {", " $0", "});"],
|
||||||
|
},
|
||||||
|
"Full Page": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "qpage",
|
||||||
|
"description": "Simple page component",
|
||||||
|
"body": [
|
||||||
|
"import { component$ } from '@builder.io/qwik';",
|
||||||
|
"",
|
||||||
|
"export default component$(() => {",
|
||||||
|
" $0",
|
||||||
|
"});",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"Qwik component (simple)": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "qcomponent$",
|
||||||
|
"description": "Simple Qwik component",
|
||||||
|
"body": [
|
||||||
|
"export const ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/}} = component$(() => {",
|
||||||
|
" return <${2:div}>$4</$2>",
|
||||||
|
"});",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Qwik component (props)": {
|
||||||
|
"scope": "typescriptreact",
|
||||||
|
"prefix": "qcomponent$ + props",
|
||||||
|
"description": "Qwik component w/ props",
|
||||||
|
"body": [
|
||||||
|
"export interface ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/}}Props {",
|
||||||
|
" $2",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"export const $1 = component$<$1Props>((props) => {",
|
||||||
|
" const ${2:count} = useSignal(0);",
|
||||||
|
" return (",
|
||||||
|
" <${3:div} on${4:Click}$={(ev) => {$5}}>",
|
||||||
|
" $6",
|
||||||
|
" </${3}>",
|
||||||
|
" );",
|
||||||
|
"});",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Qwik signal": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "quseSignal",
|
||||||
|
"description": "useSignal() declaration",
|
||||||
|
"body": ["const ${1:foo} = useSignal($2);", "$0"],
|
||||||
|
},
|
||||||
|
"Qwik store": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "quseStore",
|
||||||
|
"description": "useStore() declaration",
|
||||||
|
"body": ["const ${1:state} = useStore({", " $2", "});", "$0"],
|
||||||
|
},
|
||||||
|
"$ hook": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "q$",
|
||||||
|
"description": "$() function hook",
|
||||||
|
"body": ["$(() => {", " $0", "});", ""],
|
||||||
|
},
|
||||||
|
"useVisibleTask": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "quseVisibleTask",
|
||||||
|
"description": "useVisibleTask$() function hook",
|
||||||
|
"body": ["useVisibleTask$(({ track }) => {", " $0", "});", ""],
|
||||||
|
},
|
||||||
|
"useTask": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "quseTask$",
|
||||||
|
"description": "useTask$() function hook",
|
||||||
|
"body": [
|
||||||
|
"useTask$(({ track }) => {",
|
||||||
|
" track(() => $1);",
|
||||||
|
" $0",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"useResource": {
|
||||||
|
"scope": "javascriptreact,typescriptreact",
|
||||||
|
"prefix": "quseResource$",
|
||||||
|
"description": "useResource$() declaration",
|
||||||
|
"body": [
|
||||||
|
"const $1 = useResource$(({ track, cleanup }) => {",
|
||||||
|
" $0",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Qwik City App ⚡️
|
||||||
|
|
||||||
|
- [Qwik Docs](https://qwik.dev/)
|
||||||
|
- [Discord](https://qwik.dev/chat)
|
||||||
|
- [Qwik GitHub](https://github.com/QwikDev/qwik)
|
||||||
|
- [@QwikDev](https://twitter.com/QwikDev)
|
||||||
|
- [Vite](https://vitejs.dev/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This project is using Qwik with [QwikCity](https://qwik.dev/qwikcity/overview/). QwikCity is just an extra set of tools on top of Qwik to make it easier to build a full site, including directory-based routing, layouts, and more.
|
||||||
|
|
||||||
|
Inside your project, you'll see the following directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
├── public/
|
||||||
|
│ └── ...
|
||||||
|
└── src/
|
||||||
|
├── components/
|
||||||
|
│ └── ...
|
||||||
|
└── routes/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src/routes`: Provides the directory-based routing, which can include a hierarchy of `layout.tsx` layout files, and an `index.tsx` file as the page. Additionally, `index.ts` files are endpoints. Please see the [routing docs](https://qwik.dev/qwikcity/routing/overview/) for more info.
|
||||||
|
|
||||||
|
- `src/components`: Recommended directory for components.
|
||||||
|
|
||||||
|
- `public`: Any static assets, like images, can be placed in the public directory. Please see the [Vite public directory](https://vitejs.dev/guide/assets.html#the-public-directory) for more info.
|
||||||
|
|
||||||
|
## Add Integrations and deployment
|
||||||
|
|
||||||
|
Use the `bun qwik add` command to add additional integrations. Some examples of integrations includes: Cloudflare, Netlify or Express Server, and the [Static Site Generator (SSG)](https://qwik.dev/qwikcity/guides/static-site-generation/).
|
||||||
|
|
||||||
|
```shell
|
||||||
|
bun qwik add # or `bun qwik add`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Development mode uses [Vite's development server](https://vitejs.dev/). The `dev` command will server-side render (SSR) the output during development.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm start # or `bun start`
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: during dev mode, Vite may request a significant number of `.js` files. This does not represent a Qwik production build.
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
The preview command will create a production build of the client modules, a production build of `src/entry.preview.tsx`, and run a local server. The preview server is only for convenience to preview a production build locally and should not be used as a production server.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
bun preview # or `bun preview`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
The production build will generate client and server modules by running both client and server build commands. The build command will use Typescript to run a type check on the source code.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
bun build # or `bun build`
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { em, entity, text, boolean } from "bknd";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
|
import type { Config } from "bknd/adapter/web";
|
||||||
|
|
||||||
|
const local = registerLocalMediaAdapter();
|
||||||
|
|
||||||
|
const schema = em({
|
||||||
|
todos: entity("todos", {
|
||||||
|
title: text(),
|
||||||
|
done: boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// register your schema to get automatic type completion
|
||||||
|
type Database = (typeof schema)["DB"];
|
||||||
|
declare module "bknd" {
|
||||||
|
interface DB extends Database {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connection: {
|
||||||
|
url: "file:data.db",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
// the seed option is only executed if the database was empty
|
||||||
|
seed: async (ctx) => {
|
||||||
|
// create some entries
|
||||||
|
await ctx.em.mutator("todos").insertMany([
|
||||||
|
{ title: "Learn bknd", done: true },
|
||||||
|
{ title: "Build something cool", done: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// and create a user
|
||||||
|
await ctx.app.module.auth.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
data: schema.toJSON(),
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "random_gibberish_please_change_this",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
enabled: true,
|
||||||
|
adapter: local({
|
||||||
|
path: "./public/uploads",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminOptions: {
|
||||||
|
adminBasepath: "/admin",
|
||||||
|
assetsPath: "/admin/",
|
||||||
|
logoReturnPath: "../..",
|
||||||
|
},
|
||||||
|
} satisfies Config<"standalone">;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { globalIgnores } from "eslint/config";
|
||||||
|
import { qwikEslint9Plugin } from "eslint-plugin-qwik";
|
||||||
|
|
||||||
|
const ignores = [
|
||||||
|
"**/*.log",
|
||||||
|
"**/.DS_Store",
|
||||||
|
"**/*.",
|
||||||
|
".vscode/settings.json",
|
||||||
|
"**/.history",
|
||||||
|
"**/.yarn",
|
||||||
|
"**/bazel-*",
|
||||||
|
"**/bazel-bin",
|
||||||
|
"**/bazel-out",
|
||||||
|
"**/bazel-qwik",
|
||||||
|
"**/bazel-testlogs",
|
||||||
|
"**/dist",
|
||||||
|
"**/dist-dev",
|
||||||
|
"**/lib",
|
||||||
|
"**/lib-types",
|
||||||
|
"**/etc",
|
||||||
|
"**/external",
|
||||||
|
"**/node_modules",
|
||||||
|
"**/temp",
|
||||||
|
"**/tsc-out",
|
||||||
|
"**/tsdoc-metadata.json",
|
||||||
|
"**/target",
|
||||||
|
"**/output",
|
||||||
|
"**/rollup.config.js",
|
||||||
|
"**/build",
|
||||||
|
"**/.cache",
|
||||||
|
"**/.vscode",
|
||||||
|
"**/.rollup.cache",
|
||||||
|
"**/dist",
|
||||||
|
"**/tsconfig.tsbuildinfo",
|
||||||
|
"**/vite.config.ts",
|
||||||
|
"**/*.spec.tsx",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/.netlify",
|
||||||
|
"**/pnpm-lock.yaml",
|
||||||
|
"**/package-lock.json",
|
||||||
|
"**/yarn.lock",
|
||||||
|
"**/server",
|
||||||
|
"eslint.config.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
globalIgnores(ignores),
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
qwikEslint9Plugin.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
...globals.es2021,
|
||||||
|
...globals.serviceworker,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "my-qwik-empty-starter",
|
||||||
|
"description": "Blank project with routing included",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"engines-annotation": "Mostly required by sharp which needs a Node-API v9 compatible runtime",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "qwik build",
|
||||||
|
"build.client": "vite build",
|
||||||
|
"build.preview": "vite build --ssr src/entry.preview.tsx",
|
||||||
|
"build.types": "tsc --incremental --noEmit",
|
||||||
|
"deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'",
|
||||||
|
"dev": "vite --mode ssr",
|
||||||
|
"dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
|
||||||
|
"fmt": "prettier --write .",
|
||||||
|
"fmt.check": "prettier --check .",
|
||||||
|
"lint": "eslint \"src/**/*.ts*\"",
|
||||||
|
"preview": "qwik build preview && vite preview --open",
|
||||||
|
"start": "vite --open --mode ssr",
|
||||||
|
"qwik": "qwik",
|
||||||
|
"typegen": "bunx tsx node_modules/.bin/bknd types --outfile bknd-types.d.ts",
|
||||||
|
"postinstall": "bun run bknd copy-assets --out public/admin"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"bknd": "file:../../app"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@builder.io/qwik": "^1.19.2",
|
||||||
|
"@builder.io/qwik-city": "^1.19.2",
|
||||||
|
"@eslint/js": "^9",
|
||||||
|
"@types/node": "20.19.0",
|
||||||
|
"eslint": "9.32.0",
|
||||||
|
"eslint-plugin-qwik": "^1.19.2",
|
||||||
|
"globals": "16.4.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"typescript-eslint": "8.38.0",
|
||||||
|
"typescript-plugin-css-modules": "latest",
|
||||||
|
"undici": "*",
|
||||||
|
"vite": "7.3.1",
|
||||||
|
"vite-tsconfig-paths": "^4.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 500 500"><g clip-path="url(#a)"><circle cx="250" cy="250" r="250" fill="#fff"/><path fill="#18B6F6" d="m367.87 418.45-61.17-61.18-.94.13v-.67L175.7 227.53l32.05-31.13L188.9 87.73 99.56 199.09c-15.22 15.42-18.03 40.51-7.08 59.03l55.83 93.11a46.82 46.82 0 0 0 40.73 22.81l27.65-.27 151.18 44.68Z"/><path fill="#AC7EF4" d="m401.25 196.94-12.29-22.81-6.41-11.67-2.54-4.56-.26.26-33.66-58.63a47.07 47.07 0 0 0-41.27-23.75l-29.51.8-88.01.28a47.07 47.07 0 0 0-40.33 23.34L93.4 207l95.76-119.54L314.7 226.19l-22.3 22.67 13.35 108.54.13-.26v.26h-.26l.26.27 10.42 10.2 50.62 49.78c2.13 2 5.6-.4 4.13-2.96l-31.25-61.85 54.5-101.3 1.73-2c.67-.81 1.33-1.62 1.87-2.42a46.8 46.8 0 0 0 3.34-50.18Z"/><path fill="#fff" d="M315.1 225.65 189.18 87.6l17.9 108.14L175 227l130.5 130.27-11.75-108.14 21.37-23.48Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h500v500H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 947 B |
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||||
|
"name": "qwik-project-name",
|
||||||
|
"short_name": "Welcome to Qwik",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"description": "A Qwik project app."
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -0,0 +1,4 @@
|
|||||||
|
// This file can be used to add references for global types like `vite/client`.
|
||||||
|
|
||||||
|
// Add global `vite/client` types. For more info, see: https://vitejs.dev/guide/features#client-types
|
||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from "@builder.io/qwik-city";
|
||||||
|
import { component$ } from "@builder.io/qwik";
|
||||||
|
import { useLocation } from "@builder.io/qwik-city";
|
||||||
|
|
||||||
|
export default component$(() => {
|
||||||
|
const loc = useLocation();
|
||||||
|
return (
|
||||||
|
<footer class="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||||
|
<Link
|
||||||
|
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||||
|
href={loc.url.pathname === '/' ? '/user' : '/'}
|
||||||
|
>
|
||||||
|
<img aria-hidden src="/file.svg" alt="File icon" width="16" height="16" />
|
||||||
|
{loc.url.pathname === "/" ? "User" : "Home"}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||||
|
href="/admin/data"
|
||||||
|
>
|
||||||
|
<img aria-hidden src="/window.svg" alt="Window icon" width="16" height="16" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||||
|
href="https://bknd.io"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img aria-hidden src="/globe.svg" alt="Globe icon" width="16" height="16" />
|
||||||
|
Go to bknd.io →
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { component$ } from "@builder.io/qwik";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default component$<Props>(({ items }) => {
|
||||||
|
return (
|
||||||
|
<ol class="list-inside list-decimal text-sm text-center sm:text-left w-full text-center">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i} class={`${i < items.length - 1 ? "mb-2" : ""}`}>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* WHAT IS THIS FILE?
|
||||||
|
*
|
||||||
|
* Development entry point using only client-side modules:
|
||||||
|
* - Do not use this mode in production!
|
||||||
|
* - No SSR
|
||||||
|
* - No portion of the application is pre-rendered on the server.
|
||||||
|
* - All of the application is running eagerly in the browser.
|
||||||
|
* - More code is transferred to the browser than in SSR mode.
|
||||||
|
* - Optimizer/Serialization/Deserialization code is not exercised!
|
||||||
|
*/
|
||||||
|
import { render, type RenderOptions } from "@builder.io/qwik";
|
||||||
|
import Root from "./root";
|
||||||
|
|
||||||
|
export default function (opts: RenderOptions) {
|
||||||
|
return render(document, <Root />, opts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* WHAT IS THIS FILE?
|
||||||
|
*
|
||||||
|
* It's the bundle entry point for `npm run preview`.
|
||||||
|
* That is, serving your app built in production mode.
|
||||||
|
*
|
||||||
|
* Feel free to modify this file, but don't remove it!
|
||||||
|
*
|
||||||
|
* Learn more about Vite's preview command:
|
||||||
|
* - https://vitejs.dev/config/preview-options.html#preview-options
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
import { createQwikCity } from "@builder.io/qwik-city/middleware/node";
|
||||||
|
import qwikCityPlan from "@qwik-city-plan";
|
||||||
|
// make sure qwikCityPlan is imported before entry
|
||||||
|
import render from "./entry.ssr";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default export is the QwikCity adapter used by Vite preview.
|
||||||
|
*/
|
||||||
|
export default createQwikCity({ render, qwikCityPlan });
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* WHAT IS THIS FILE?
|
||||||
|
*
|
||||||
|
* SSR entry point, in all cases the application is rendered outside the browser, this
|
||||||
|
* entry point will be the common one.
|
||||||
|
*
|
||||||
|
* - Server (express, cloudflare...)
|
||||||
|
* - npm run start
|
||||||
|
* - npm run preview
|
||||||
|
* - npm run build
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
renderToStream,
|
||||||
|
type RenderToStreamOptions,
|
||||||
|
} from "@builder.io/qwik/server";
|
||||||
|
import Root from "./root";
|
||||||
|
|
||||||
|
export default function (opts: RenderToStreamOptions) {
|
||||||
|
return renderToStream(<Root />, {
|
||||||
|
...opts,
|
||||||
|
// Use container attributes to set attributes on the html tag.
|
||||||
|
containerAttributes: {
|
||||||
|
lang: "en-us",
|
||||||
|
...opts.containerAttributes,
|
||||||
|
},
|
||||||
|
serverData: {
|
||||||
|
...opts.serverData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createBknd } from "bknd/adapter/web";
|
||||||
|
import bkndConfig from "../../bknd.config";
|
||||||
|
import type { EnvGetter } from "@builder.io/qwik-city/middleware/request-handler";
|
||||||
|
|
||||||
|
export const getApp = async (env?: EnvGetter) => createBknd({ mode: "standalone", options: bkndConfig }, env);
|
||||||
|
|
||||||
|
export const handler = async (req: Request, env?: EnvGetter) => {
|
||||||
|
return (await getApp(env)).serve()(req);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApi = async (opts?: { headers?: Headers; verify?: boolean }) => {
|
||||||
|
return (await getApp()).getApi(opts);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { component$, isDev } from "@builder.io/qwik";
|
||||||
|
import { QwikCityProvider, RouterOutlet } from "@builder.io/qwik-city";
|
||||||
|
import { useDocumentHead, useLocation } from "@builder.io/qwik-city";
|
||||||
|
|
||||||
|
|
||||||
|
import "./global.css";
|
||||||
|
|
||||||
|
export default component$(() => {
|
||||||
|
/**
|
||||||
|
* The root of a QwikCity site always start with the <QwikCityProvider> component,
|
||||||
|
* immediately followed by the document's <head> and <body>.
|
||||||
|
*
|
||||||
|
* Don't remove the `<head>` and `<body>` elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QwikCityProvider>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
{!isDev && (
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href={`${import.meta.env.BASE_URL}manifest.json`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<RouterHead />
|
||||||
|
</head>
|
||||||
|
<body lang="en">
|
||||||
|
<RouterOutlet />
|
||||||
|
</body>
|
||||||
|
</QwikCityProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const RouterHead = component$(() => {
|
||||||
|
const head = useDocumentHead();
|
||||||
|
const loc = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title>{head.title}</title>
|
||||||
|
|
||||||
|
<link rel="canonical" href={loc.url.href} />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
{head.meta.map((m) => (
|
||||||
|
<meta key={m.key} {...m} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{head.links.map((l) => (
|
||||||
|
<link key={l.key} {...l} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{head.styles.map((s) => (
|
||||||
|
<style
|
||||||
|
key={s.key}
|
||||||
|
{...s.props}
|
||||||
|
{...(s.props?.dangerouslySetInnerHTML
|
||||||
|
? {}
|
||||||
|
: { dangerouslySetInnerHTML: s.style })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{head.scripts.map((s) => (
|
||||||
|
<script
|
||||||
|
key={s.key}
|
||||||
|
{...s.props}
|
||||||
|
{...(s.props?.dangerouslySetInnerHTML
|
||||||
|
? {}
|
||||||
|
: { dangerouslySetInnerHTML: s.script })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { component$ } from "@builder.io/qwik";
|
||||||
|
import { Form, routeAction$, routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
|
||||||
|
import Footer from "~/components/footer";
|
||||||
|
import List from "~/components/list";
|
||||||
|
import { getApi } from "~/lib/bknd";
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
title: string | undefined;
|
||||||
|
done: boolean | undefined;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetTodos = routeLoader$(async () => {
|
||||||
|
const api = await getApi();
|
||||||
|
const limit = 5;
|
||||||
|
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
|
||||||
|
const total = todos.body.meta.total as number;
|
||||||
|
return { total, todos, limit };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCompleteTodo = routeAction$(async (data) => {
|
||||||
|
const api = await getApi();
|
||||||
|
const todo = data as unknown as Todo;
|
||||||
|
await api.data.updateOne("todos", todo.id, {
|
||||||
|
done: !todo.done,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeleteTodo = routeAction$(async (data) => {
|
||||||
|
const api = await getApi();
|
||||||
|
const todo = data as unknown as Todo;
|
||||||
|
await api.data.deleteOne("todos", todo.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const useAddTodo = routeAction$(async (data) => {
|
||||||
|
const api = await getApi();
|
||||||
|
await api.data.createOne("todos", { title: data.title as string });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default component$(() => {
|
||||||
|
const {
|
||||||
|
value: { limit, todos, total },
|
||||||
|
} = useGetTodos();
|
||||||
|
const addTodoAction = useAddTodo();
|
||||||
|
const completeTodoAction = useCompleteTodo();
|
||||||
|
const deleteTodoAction = useDeleteTodo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||||
|
<main class="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center justify-evenly min-w-full">
|
||||||
|
<img class="size-24" src="/favicon.svg" alt="Qwik logo" width="72" height="72" />
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img class="dark:invert" src="/bknd.svg" alt="bknd logo" width="183" height="59" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
items={[
|
||||||
|
"Get started with a full backend.",
|
||||||
|
"Focus on what matters instead of repetition.",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col border border-black/15 dark:border-white/15 w-full py-4 px-5 gap-2">
|
||||||
|
<h2 class="font-mono mb-1 opacity-70">
|
||||||
|
<code>What's next?</code>
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
{total > limit && (
|
||||||
|
<div class="bg-foreground/10 flex justify-center p-1 text-xs rounded text-foreground/40">
|
||||||
|
{total - limit} more todo(s) hidden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<div key={String(todo.id)} class="flex flex-row">
|
||||||
|
<div class="flex flex-row grow items-center gap-3 ml-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="shrink-0 cursor-pointer"
|
||||||
|
checked={Boolean(todo.done)}
|
||||||
|
onChange$={async() => {
|
||||||
|
await completeTodoAction.submit(todo);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="text-foreground/90 leading-none">{todo.title}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs"
|
||||||
|
onclick$={async () => { await deleteTodoAction.submit(todo); }}
|
||||||
|
>
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
class="flex flex-row w-full gap-3 mt-2"
|
||||||
|
key={todos.map((t) => t.id).join()}
|
||||||
|
action={addTodoAction}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
placeholder="New todo"
|
||||||
|
class="py-2 px-4 flex grow rounded-sm bg-black/5 focus:bg-black/10 dark:bg-white/5 dark:focus:bg-white/10 transition-colors outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="cursor-pointer">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const head: DocumentHead = {
|
||||||
|
title: "Qwik City 🤝 Bknd.io",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Qwik site with bknd",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RequestHandler } from "@builder.io/qwik-city";
|
||||||
|
import { handler } from "~/lib/bknd";
|
||||||
|
|
||||||
|
export const onRequest: RequestHandler = async ({
|
||||||
|
env,
|
||||||
|
url,
|
||||||
|
next,
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
getWritableStream,
|
||||||
|
}) => {
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api") || pathname !== "/") {
|
||||||
|
const response = await handler(request, env);
|
||||||
|
|
||||||
|
// skips unknown paths
|
||||||
|
if (response.status === 404) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// adds the set-cookie header
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// for redirect
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (location) {
|
||||||
|
throw redirect(response.status as any, location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream back the body
|
||||||
|
status(response.status);
|
||||||
|
if (response.body) {
|
||||||
|
await response.body?.pipeTo(getWritableStream());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { component$ } from "@builder.io/qwik";
|
||||||
|
import { Link, routeLoader$ } from "@builder.io/qwik-city";
|
||||||
|
import List from "~/components/list";
|
||||||
|
import { getApi } from "~/lib/bknd";
|
||||||
|
import Footer from "~/components/footer";
|
||||||
|
|
||||||
|
export const useGetTodos = routeLoader$(async ({ request }) => {
|
||||||
|
const api = await getApi({ verify: true, headers: request.headers });
|
||||||
|
const limit = 5;
|
||||||
|
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
|
||||||
|
const total = todos.body.meta.total as number;
|
||||||
|
const user = api.getUser();
|
||||||
|
return { total, todos, limit, user };
|
||||||
|
});
|
||||||
|
|
||||||
|
export default component$(() => {
|
||||||
|
const {
|
||||||
|
value: { todos, user },
|
||||||
|
} = useGetTodos();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||||
|
<main class="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center ">
|
||||||
|
<img
|
||||||
|
class="dark:invert size-18"
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="Qwik logo"
|
||||||
|
height={72}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img class="dark:invert" src="/bknd.svg" alt="bknd logo" width={183} height={59} />
|
||||||
|
</div>
|
||||||
|
<List items={todos.map((todo) => todo.title ?? "")} />
|
||||||
|
<Buttons />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
Logged in as {user.email}.
|
||||||
|
<Link class="font-medium underline" href={"/api/auth/logout" as string}>
|
||||||
|
Logout
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p>
|
||||||
|
Not logged in.
|
||||||
|
<Link class="font-medium underline" href={"/admin/auth/login" as string}>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs opacity-50">
|
||||||
|
Sign in with:
|
||||||
|
<b>
|
||||||
|
<code>test@bknd.io</code>
|
||||||
|
</b>
|
||||||
|
/
|
||||||
|
<b>
|
||||||
|
<code>12345678</code>
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function Buttons() {
|
||||||
|
return (
|
||||||
|
<div class="flex gap-4 items-center flex-col sm:flex-row">
|
||||||
|
<a
|
||||||
|
class="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground gap-2 text-white hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||||
|
href="https://bknd.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img class="grayscale" src="/bknd.ico" alt="bknd logomark" width={20} height={20} />
|
||||||
|
Go To Bknd.io
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||||
|
href="https://docs.bknd.io/integration/meta-framework"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Read our docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@builder.io/qwik",
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"incremental": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"outDir": "tmp",
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
/* if you do not use CSS modules, remove this line and delete the typescript-plugin-css-modules module from package.json */
|
||||||
|
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||||
|
},
|
||||||
|
"include": ["src", "./*.d.ts", "./*.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, type UserConfig } from "vite";
|
||||||
|
import { qwikVite } from "@builder.io/qwik/optimizer";
|
||||||
|
import { qwikCity } from "@builder.io/qwik-city/vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig((): UserConfig => {
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
qwikCity({
|
||||||
|
trailingSlash: false
|
||||||
|
}),
|
||||||
|
qwikVite(),
|
||||||
|
tsconfigPaths({ root: "." }),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
dist
|
|
||||||
.wrangler
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.vinxi
|
|
||||||
app.config.timestamp_*.js
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
|
|
||||||
# IDEs and editors
|
|
||||||
/.idea
|
|
||||||
.project
|
|
||||||
.classpath
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
|
|
||||||
# Temp
|
|
||||||
gitignore
|
|
||||||
|
|
||||||
# System Files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
public/admin
|
|
||||||
data.db
|
|
||||||