mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fdaf594f4 | |||
| 7d89fe4894 | |||
| 50e9be833b | |||
| 806b7427c1 | |||
| 8742938074 | |||
| 36e61cab3f | |||
| 17d4adbbfa | |||
| d052871fe0 | |||
| b0e5a49b0b | |||
| 35cbcc221a | |||
| 26d1f2b583 | |||
| 95d114ea68 | |||
| 059becbf09 | |||
| 91120091a3 | |||
| 99a812cc75 | |||
| 29a2de31c6 | |||
| 2688bf261b | |||
| 15c32c0e6d | |||
| d6f2500be2 | |||
| fa8931ad15 | |||
| 6e3060141b | |||
| 07029e3797 | |||
| 3672cc7f2c | |||
| ac32eb128e | |||
| ffdf453fea | |||
| 9dd7432e6b | |||
| 8c4a8d91a2 | |||
| 9aae6e78d6 | |||
| 317b2b50ad | |||
| a0b2dde034 | |||
| 70eaa22327 | |||
| ddfc3e599f | |||
| c11dd2bd9e | |||
| 77c85bfd5c | |||
| 732cd31e1f | |||
| 1a7670f57a | |||
| af573cc79a | |||
| 1df87c8a16 | |||
| 3c5e3f9638 | |||
| 7a0a7481c0 | |||
| bdcc81b2f1 | |||
| 94e168589d | |||
| c22339e4bf | |||
| d01058595f | |||
| fdec5f0693 | |||
| e8f2c70279 | |||
| 2c5371610b | |||
| 758a89b5d7 | |||
| e3888537f9 | |||
| a559a2eabc |
@@ -1,12 +1,15 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test";
|
||||
import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { App, createApp } from "core/test/utils";
|
||||
import { getDummyConnection } from "./helper";
|
||||
import { Hono } from "hono";
|
||||
import * as proto from "../src/data/prototype";
|
||||
import { pick } from "lodash-es";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterEach(afterAllCleanup);
|
||||
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("App tests", async () => {
|
||||
test("boots and pongs", async () => {
|
||||
@@ -19,7 +22,7 @@ describe("App tests", async () => {
|
||||
test("plugins", async () => {
|
||||
const called: string[] = [];
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
|
||||
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
||||
import * as adapter from "adapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
@@ -19,50 +19,39 @@ describe("adapter", () => {
|
||||
expect(
|
||||
omitKeys(
|
||||
await adapter.makeConfig(
|
||||
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||
{ env: { TEST: "test" } },
|
||||
),
|
||||
["connection"],
|
||||
),
|
||||
).toEqual({
|
||||
initialConfig: { server: { cors: { origin: "test" } } },
|
||||
config: { server: { cors: { origin: "test" } } },
|
||||
});
|
||||
});
|
||||
|
||||
/* it.only("...", async () => {
|
||||
const app = await adapter.createAdapterApp();
|
||||
}); */
|
||||
|
||||
it("reuses apps correctly", async () => {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const first = await adapter.createAdapterApp(
|
||||
it("allows all properties in app function", async () => {
|
||||
const called = mock(() => null);
|
||||
const config = await adapter.makeConfig(
|
||||
{
|
||||
initialConfig: { server: { cors: { origin: "random" } } },
|
||||
app: (env) => ({
|
||||
connection: { url: "test" },
|
||||
config: { server: { cors: { origin: "test" } } },
|
||||
options: {
|
||||
mode: "db",
|
||||
},
|
||||
undefined,
|
||||
{ id },
|
||||
onBuilt: () => {
|
||||
called();
|
||||
expect(env).toEqual({ foo: "bar" });
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ foo: "bar" },
|
||||
);
|
||||
const second = await adapter.createAdapterApp();
|
||||
const third = await adapter.createAdapterApp(undefined, undefined, { id });
|
||||
|
||||
await first.build();
|
||||
await second.build();
|
||||
await third.build();
|
||||
|
||||
expect(first.toJSON().server.cors.origin).toEqual("random");
|
||||
expect(first).toBe(third);
|
||||
expect(first).not.toBe(second);
|
||||
expect(second).not.toBe(third);
|
||||
expect(second.toJSON().server.cors.origin).toEqual("*");
|
||||
|
||||
// recreate the first one
|
||||
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
|
||||
await first2.build();
|
||||
expect(first2).not.toBe(first);
|
||||
expect(first2).not.toBe(third);
|
||||
expect(first2).not.toBe(second);
|
||||
expect(first2.toJSON().server.cors.origin).toEqual("*");
|
||||
expect(config.connection).toEqual({ url: "test" });
|
||||
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||
expect(config.options).toEqual({ mode: "db" });
|
||||
await config.onBuilt?.(null as any);
|
||||
expect(called).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
|
||||
@@ -42,7 +42,6 @@ describe("Api", async () => {
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
const params = api.getParams();
|
||||
console.log(params);
|
||||
expect(params.token).toBe(token);
|
||||
expect(params.token_transport).toBe("cookie");
|
||||
expect(params.host).toBe("http://example.com");
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import type { ModuleBuildContext } from "../../src";
|
||||
import { App, createApp } from "core/test/utils";
|
||||
import * as proto from "../../src/data/prototype";
|
||||
import * as proto from "data/prototype";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("App", () => {
|
||||
test("use db mode by default", async () => {
|
||||
const app = createApp();
|
||||
await app.build();
|
||||
|
||||
expect(app.mode).toBe("db");
|
||||
expect(app.isReadOnly()).toBe(false);
|
||||
expect(app.modules instanceof DbModuleManager).toBe(true);
|
||||
});
|
||||
|
||||
test("seed includes ctx and app", async () => {
|
||||
const called = mock(() => null);
|
||||
await createApp({
|
||||
@@ -29,7 +43,7 @@ describe("App", () => {
|
||||
expect(called).toHaveBeenCalled();
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: proto
|
||||
.em({
|
||||
todos: proto.entity("todos", {
|
||||
@@ -139,7 +153,7 @@ describe("App", () => {
|
||||
|
||||
test("getMcpClient", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
|
||||
let server: McpServer;
|
||||
beforeEach(async () => {
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { registries } from "index";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("mcp", () => {
|
||||
it("should have tools", async () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
|
||||
beforeEach(async () => {
|
||||
const time = performance.now();
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
|
||||
beforeEach(async () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import type { McpServer } from "bknd/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [x] config_server_get
|
||||
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
|
||||
let server: McpServer;
|
||||
beforeAll(async () => {
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("mcp system", async () => {
|
||||
let server: McpServer;
|
||||
beforeAll(async () => {
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { syncConfig } from "plugins/dev/sync-config.plugin";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("syncConfig", () => {
|
||||
it("should only sync if enabled", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = createApp();
|
||||
await app.build();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: false,
|
||||
includeFirstBoot: false,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: false,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: true,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should respect secrets", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
await syncConfig({
|
||||
write: async (config) => {
|
||||
called();
|
||||
expect(config.auth.jwt.secret).toBe("test");
|
||||
},
|
||||
enabled: true,
|
||||
includeSecrets: true,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
|
||||
await syncConfig({
|
||||
write: async (config) => {
|
||||
called();
|
||||
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
|
||||
// but it's required to get the app running
|
||||
expect(config.auth.jwt.secret).toBe("");
|
||||
},
|
||||
enabled: true,
|
||||
includeSecrets: false,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { registries } from "../../src";
|
||||
import { createApp } from "core/test/utils";
|
||||
import * as proto from "../../src/data/prototype";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("repros", async () => {
|
||||
/**
|
||||
@@ -88,7 +92,7 @@ describe("repros", async () => {
|
||||
fns.relation(schema.product_likes).manyToOne(schema.users);
|
||||
},
|
||||
);
|
||||
const app = createApp({ initialConfig: { data: schema.toJSON() } });
|
||||
const app = createApp({ config: { data: schema.toJSON() } });
|
||||
await app.build();
|
||||
|
||||
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
@@ -248,7 +248,7 @@ describe("Core Utils", async () => {
|
||||
expect(utils.getContentName(request)).toBe(name);
|
||||
});
|
||||
|
||||
test.only("detectImageDimensions", async () => {
|
||||
test("detectImageDimensions", async () => {
|
||||
// wrong
|
||||
// @ts-expect-error
|
||||
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
|
||||
@@ -267,12 +267,12 @@ describe("Core Utils", async () => {
|
||||
});
|
||||
|
||||
describe("dates", () => {
|
||||
test.only("formats local time", () => {
|
||||
test("formats local time", () => {
|
||||
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
||||
console.log(utils.datetimeStringUTC(new Date()));
|
||||
/*console.log(utils.datetimeStringUTC(new Date()));
|
||||
console.log(utils.datetimeStringUTC());
|
||||
console.log(new Date());
|
||||
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
|
||||
|
||||
import { DataController } from "../../src/data/api/DataController";
|
||||
import { dataConfigSchema } from "../../src/data/data-schema";
|
||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
|
||||
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
|
||||
import { Entity, EntityManager, type EntityData } from "data/entities";
|
||||
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
const dataConfig = parse(dataConfigSchema, {});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
import { TextField } from "data/fields";
|
||||
import { JoinBuilder } from "data/entities/query/JoinBuilder";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("[data] JoinBuilder", async () => {
|
||||
test("missing relation", async () => {
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
} from "data/relations";
|
||||
import { NumberField, TextField } from "data/fields";
|
||||
import * as proto from "data/prototype";
|
||||
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
|
||||
import { getDummyConnection } from "../../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { MutatorEvents } from "data/events";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
|
||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("[data] Mutator (base)", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Kysely, Transaction } from "kysely";
|
||||
import { TextField } from "data/fields";
|
||||
import { em as $em, entity as $entity, text as $text } from "data/prototype";
|
||||
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
import { RepositoryEvents } from "data/events";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
type E = Kysely<any> | Transaction<any>;
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
|
||||
import { TextField } from "data/fields";
|
||||
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
|
||||
import { WithBuilder } from "data/entities/query/WithBuilder";
|
||||
import { schemaToEm } from "../../helper";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
|
||||
|
||||
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
|
||||
expect(index.name).toEqual("idx_test_name");
|
||||
expect(index.unique).toEqual(false);
|
||||
});
|
||||
|
||||
test("it fails on non-unique", async () => {
|
||||
const field = new TestField("name", { required: false });
|
||||
|
||||
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
|
||||
expect(() => new EntityIndex(entity, [field])).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
type BaseRelationConfig,
|
||||
EntityRelation,
|
||||
EntityRelationAnchor,
|
||||
ManyToManyRelation,
|
||||
RelationTypes,
|
||||
} from "data/relations";
|
||||
import * as proto from "data/prototype";
|
||||
|
||||
class TestEntityRelation extends EntityRelation {
|
||||
constructor(config?: BaseRelationConfig) {
|
||||
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
|
||||
const relation2 = new TestEntityRelation({ required: true });
|
||||
expect(relation2.required).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly produces the relation name", async () => {
|
||||
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
|
||||
expect(relation.getName()).not.toContain(",");
|
||||
expect(relation.getName()).toBe("mn_apps_organizations");
|
||||
|
||||
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
|
||||
connectionTableMappedName: "appOrganizations",
|
||||
});
|
||||
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-21
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
|
||||
return { url: "http://127.0.0.1:8080" };
|
||||
}
|
||||
|
||||
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
|
||||
const _oldConsoles = {
|
||||
debug: console.debug,
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
}
|
||||
|
||||
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
return { sql, parameters };
|
||||
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
|
||||
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
console.log("$", sqlFormat(sql), "\n[params]", parameters);
|
||||
console.info("$", sqlFormat(sql), "\n[params]", parameters);
|
||||
}
|
||||
|
||||
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp } from "../../src";
|
||||
import type { AuthResponse } from "../../src/auth";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp, type AuthResponse } from "../../src";
|
||||
import { auth } from "../../src/auth/middlewares";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterEach(afterAllCleanup);
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -66,9 +63,10 @@ const configs = {
|
||||
};
|
||||
|
||||
function createAuthApp() {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: configs.auth,
|
||||
},
|
||||
});
|
||||
@@ -151,8 +149,8 @@ describe("integration auth", () => {
|
||||
|
||||
const { data: users } = await app.em.repository("users").findMany();
|
||||
expect(users.length).toBe(2);
|
||||
expect(users[0].email).toBe(configs.users.normal.email);
|
||||
expect(users[1].email).toBe(configs.users.admin.email);
|
||||
expect(users[0]?.email).toBe(configs.users.normal.email);
|
||||
expect(users[1]?.email).toBe(configs.users.admin.email);
|
||||
});
|
||||
|
||||
it("should log you in with API", async () => {
|
||||
@@ -223,7 +221,7 @@ describe("integration auth", () => {
|
||||
|
||||
app.server.get("/get", auth(), async (c) => {
|
||||
return c.json({
|
||||
user: c.get("auth").user ?? null,
|
||||
user: c.get("auth")?.user ?? null,
|
||||
});
|
||||
});
|
||||
app.server.get("/wait", auth(), async (c) => {
|
||||
@@ -242,7 +240,7 @@ describe("integration auth", () => {
|
||||
{
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const res = await app.server.request("/get");
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as any;
|
||||
expect(data.user).toBe(null);
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { Api } from "../../src/Api";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("integration config", () => {
|
||||
it("should create an entity", async () => {
|
||||
|
||||
@@ -6,17 +6,20 @@ import { createApp } from "core/test/utils";
|
||||
import { mergeObject, randomString } from "../../src/core/utils";
|
||||
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => {
|
||||
disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
const path = `${assetsPath}/image.png`;
|
||||
|
||||
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: mergeObject(
|
||||
{
|
||||
enabled: true,
|
||||
@@ -40,9 +43,6 @@ function makeName(ext: string) {
|
||||
return randomString(10) + "." + ext;
|
||||
}
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("MediaController", () => {
|
||||
test("accepts direct", async () => {
|
||||
const app = await makeApp();
|
||||
|
||||
@@ -3,11 +3,14 @@ import { createApp } from "core/test/utils";
|
||||
import { AuthController } from "../../src/auth/api/AuthController";
|
||||
import { em, entity, make, text } from "data/prototype";
|
||||
import { AppAuth, type ModuleBuildContext } from "modules";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("AppAuth", () => {
|
||||
test.only("...", () => {
|
||||
test.skip("...", () => {
|
||||
const auth = new AppAuth({});
|
||||
console.log(auth.toJSON());
|
||||
console.log(auth.config);
|
||||
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("registers auth middleware for bknd routes only", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("should allow additional user fields", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
entity_name: "users",
|
||||
enabled: true,
|
||||
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("ensure user field configs is always correct", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
|
||||
import { moduleTestSuite } from "./module-test-suite";
|
||||
|
||||
describe("AppMedia", () => {
|
||||
test.only("...", () => {
|
||||
test.skip("...", () => {
|
||||
const media = new AppMedia();
|
||||
console.log(media.toJSON());
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
entity_name: "media",
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { it, expect, describe } from "bun:test";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { TABLE_NAME } from "modules/db/migrations";
|
||||
|
||||
describe("DbModuleManager", () => {
|
||||
it("should extract secrets", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const m = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await m.build();
|
||||
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
|
||||
await m.save();
|
||||
});
|
||||
|
||||
it("should work with initial secrets", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const db = dummyConnection.kysely;
|
||||
const m = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
"auth.jwt.secret": "test",
|
||||
},
|
||||
});
|
||||
await m.build();
|
||||
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
|
||||
|
||||
const getSecrets = () =>
|
||||
db
|
||||
.selectFrom(TABLE_NAME)
|
||||
.selectAll()
|
||||
.where("type", "=", "secrets")
|
||||
.executeTakeFirst()
|
||||
.then((r) => r?.json);
|
||||
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
|
||||
// also after rebuild
|
||||
await m.build();
|
||||
await m.save();
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
|
||||
// and ignore if already present
|
||||
const m2 = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
"auth.jwt.secret": "something completely different",
|
||||
},
|
||||
});
|
||||
await m2.build();
|
||||
await m2.save();
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
import { Module } from "modules/Module";
|
||||
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
|
||||
import { getDefaultConfig } from "modules/ModuleManager";
|
||||
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
|
||||
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { s, stripMark } from "core/utils/schema";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import { entity, text } from "data/prototype";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("ModuleManager", async () => {
|
||||
test("s1: no config, no build", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
|
||||
const db = c2.dummyConnection.kysely;
|
||||
|
||||
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||
initial: { version: version - 1, ...json },
|
||||
initial: { version: version - 1, ...json } as any,
|
||||
});
|
||||
await mm2.syncConfigTable();
|
||||
await db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type InitialModuleConfigs, createApp } from "../../../src";
|
||||
|
||||
import { type Kysely, sql } from "kysely";
|
||||
@@ -6,6 +6,10 @@ import { getDummyConnection } from "../../helper";
|
||||
import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
// app expects migratable config to be present in database
|
||||
async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
|
||||
+9
-4
@@ -3,20 +3,25 @@ import c from "picocolors";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = ["jsonv-ts/*", "wrangler", ...deps];
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
await esbuild.build({
|
||||
const result = await esbuild.build({
|
||||
entryPoints: ["./src/cli/index.ts"],
|
||||
outdir: "./dist/cli",
|
||||
platform: "node",
|
||||
minify: false,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
metafile: true,
|
||||
bundle: true,
|
||||
external: ["jsonv-ts", "jsonv-ts/*"],
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -26,7 +31,7 @@ const result = await Bun.build({
|
||||
outdir: "./dist/cli",
|
||||
env: "PUBLIC_*",
|
||||
minify: true,
|
||||
external: ["jsonv-ts", "jsonv-ts/*"],
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
|
||||
@@ -252,6 +252,8 @@ async function buildAdapters() {
|
||||
platform: "neutral",
|
||||
entry: ["src/adapter/index.ts"],
|
||||
outDir: "dist/adapter",
|
||||
// only way to keep @vite-ignore comments
|
||||
minify: false,
|
||||
}),
|
||||
|
||||
// specific adatpers
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
|
||||
async function generate() {
|
||||
console.info("Generating MCP documentation...");
|
||||
const app = await createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
+7
-5
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.17.2",
|
||||
"version": "0.18.0-rc.4",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||
"prepublishOnly": "bun run types && bun run test && bun run test:node && VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
|
||||
"prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
|
||||
"postpublish": "rm -f README.md",
|
||||
"test": "ALL_TESTS=1 bun test --bail",
|
||||
"test:all": "bun run test && bun run test:node",
|
||||
@@ -78,10 +78,10 @@
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.8.38",
|
||||
"@cloudflare/vitest-pool-workers": "^0.9.3",
|
||||
"@cloudflare/workers-types": "^4.20250606.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/vite-dev-server": "^0.19.1",
|
||||
"@hono/vite-dev-server": "^0.21.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
@@ -130,7 +130,9 @@
|
||||
"vite-plugin-circular-dependency": "^0.5.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.9",
|
||||
"wouter": "^3.6.0"
|
||||
"wouter": "^3.6.0",
|
||||
"wrangler": "^4.37.1",
|
||||
"miniflare": "^4.20250913.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.14.3"
|
||||
|
||||
+33
-21
@@ -5,17 +5,18 @@ import type { em as prototypeEm } from "data/prototype";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
ModuleManager,
|
||||
type InitialModuleConfigs,
|
||||
type ModuleBuildContext,
|
||||
type ModuleConfigs,
|
||||
type ModuleManagerOptions,
|
||||
type Modules,
|
||||
ModuleManager,
|
||||
type ModuleBuildContext,
|
||||
type ModuleManagerOptions,
|
||||
} from "modules/ModuleManager";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
import type { MaybePromise } from "core/types";
|
||||
import type { MaybePromise, PartialRec } from "core/types";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||
|
||||
@@ -93,20 +94,23 @@ export type AppOptions = {
|
||||
email?: IEmailDriver;
|
||||
cache?: ICacheDriver;
|
||||
};
|
||||
mode?: "db" | "code";
|
||||
readonly?: boolean;
|
||||
};
|
||||
export type CreateAppConfig = {
|
||||
/**
|
||||
* bla
|
||||
*/
|
||||
connection?: Connection | { url: string };
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
config?: PartialRec<ModuleConfigs>;
|
||||
options?: AppOptions;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
export type AppConfig = { version: number } & ModuleConfigs;
|
||||
export type LocalApiOptions = Request | ApiOptions;
|
||||
|
||||
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
|
||||
export class App<
|
||||
C extends Connection = Connection,
|
||||
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
|
||||
Options extends AppOptions = AppOptions,
|
||||
> {
|
||||
static readonly Events = AppEvents;
|
||||
|
||||
modules: ModuleManager;
|
||||
@@ -121,8 +125,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
|
||||
constructor(
|
||||
public connection: C,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private options?: Options,
|
||||
_config?: Config,
|
||||
public options?: Options,
|
||||
) {
|
||||
this.drivers = options?.drivers ?? {};
|
||||
|
||||
@@ -134,9 +138,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this.plugins.set(config.name, config);
|
||||
}
|
||||
this.runPlugins("onBoot");
|
||||
this.modules = new ModuleManager(connection, {
|
||||
|
||||
// use db manager by default
|
||||
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
|
||||
|
||||
this.modules = new Manager(connection, {
|
||||
...(options?.manager ?? {}),
|
||||
initial: _initialConfig,
|
||||
initial: _config,
|
||||
onUpdated: this.onUpdated.bind(this),
|
||||
onFirstBoot: this.onFirstBoot.bind(this),
|
||||
onServerInit: this.onServerInit.bind(this),
|
||||
@@ -145,6 +153,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.options?.mode ?? "db";
|
||||
}
|
||||
|
||||
isReadOnly() {
|
||||
return Boolean(this.mode === "code" || this.options?.readonly);
|
||||
}
|
||||
|
||||
get emgr() {
|
||||
return this.modules.ctx().emgr;
|
||||
}
|
||||
@@ -175,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
return results as any;
|
||||
}
|
||||
|
||||
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
||||
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
|
||||
// prevent multiple concurrent builds
|
||||
if (this._building) {
|
||||
while (this._building) {
|
||||
@@ -188,7 +204,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this._building = true;
|
||||
|
||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||
await this.modules.build({ fetch: options?.fetch });
|
||||
await this.modules.build();
|
||||
|
||||
const { guard } = this.modules.ctx();
|
||||
|
||||
@@ -215,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this._building = false;
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.mutateConfigSafe(module);
|
||||
}
|
||||
|
||||
get server() {
|
||||
return this.modules.server;
|
||||
}
|
||||
@@ -377,5 +389,5 @@ export function createApp(config: CreateAppConfig = {}) {
|
||||
throw new Error("Invalid connection");
|
||||
}
|
||||
|
||||
return new App(config.connection, config.initialConfig, config.options);
|
||||
return new App(config.connection, config.config, config.options);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TestRunner } from "core/test";
|
||||
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
|
||||
import type { BkndConfig, DefaultArgs } from "./index";
|
||||
import type { App } from "App";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
export function adapterTestSuite<
|
||||
Config extends BkndConfig = BkndConfig,
|
||||
@@ -13,24 +14,17 @@ export function adapterTestSuite<
|
||||
label = "app",
|
||||
overrides = {},
|
||||
}: {
|
||||
makeApp: (
|
||||
config: Config,
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions | FrameworkOptions,
|
||||
) => Promise<App>;
|
||||
makeHandler?: (
|
||||
config?: Config,
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions | FrameworkOptions,
|
||||
) => (request: Request) => Promise<Response>;
|
||||
makeApp: (config: Config, args?: Args) => Promise<App>;
|
||||
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
|
||||
label?: string;
|
||||
overrides?: {
|
||||
dbUrl?: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const { test, expect, mock } = testRunner;
|
||||
const id = crypto.randomUUID();
|
||||
const { test, expect, mock, beforeAll, afterAll } = testRunner;
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
test(`creates ${label}`, async () => {
|
||||
const beforeBuild = mock(async () => null) as any;
|
||||
@@ -39,7 +33,7 @@ export function adapterTestSuite<
|
||||
const config = {
|
||||
app: (env) => ({
|
||||
connection: { url: env.url },
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: { cors: { origin: env.origin } },
|
||||
},
|
||||
}),
|
||||
@@ -53,11 +47,10 @@ export function adapterTestSuite<
|
||||
url: overrides.dbUrl ?? ":memory:",
|
||||
origin: "localhost",
|
||||
} as any,
|
||||
{ force: false, id },
|
||||
);
|
||||
expect(app).toBeDefined();
|
||||
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||
expect(beforeBuild).toHaveBeenCalledTimes(1);
|
||||
expect(beforeBuild).toHaveBeenCalledTimes(2);
|
||||
expect(onBuilt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -68,8 +61,8 @@ export function adapterTestSuite<
|
||||
return { res, data };
|
||||
};
|
||||
|
||||
test("responds with the same app id", async () => {
|
||||
const fetcher = makeHandler(undefined, undefined, { force: false, id });
|
||||
/* test.skip("responds with the same app id", async () => {
|
||||
const fetcher = makeHandler(undefined, undefined, { id });
|
||||
|
||||
const { res, data } = await getConfig(fetcher);
|
||||
expect(res.ok).toBe(true);
|
||||
@@ -77,14 +70,14 @@ export function adapterTestSuite<
|
||||
expect(data.server.cors.origin).toEqual("localhost");
|
||||
});
|
||||
|
||||
test("creates fresh & responds to api config", async () => {
|
||||
test.skip("creates fresh & responds to api config", async () => {
|
||||
// set the same id, but force recreate
|
||||
const fetcher = makeHandler(undefined, undefined, { id, force: true });
|
||||
const fetcher = makeHandler(undefined, undefined, { id });
|
||||
|
||||
const { res, data } = await getConfig(fetcher);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.server.cors.origin).toEqual("*");
|
||||
});
|
||||
}); */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
||||
describe("astro adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: astro.getApp,
|
||||
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
|
||||
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
|
||||
type AstroEnv = NodeJS.ProcessEnv;
|
||||
type TAstro = {
|
||||
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
||||
export async function getApp<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts: FrameworkOptions = {},
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? import.meta.env, opts);
|
||||
return await createFrameworkApp(config, args ?? import.meta.env);
|
||||
}
|
||||
|
||||
export function serve<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
|
||||
return async (fnArgs: TAstro) => {
|
||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { App } from "bknd";
|
||||
import { handle } from "hono/aws-lambda";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
|
||||
type AwsLambdaEnv = object;
|
||||
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
||||
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
||||
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
): Promise<App> {
|
||||
let additional: Partial<RuntimeBkndConfig> = {
|
||||
adminOptions,
|
||||
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
...additional,
|
||||
},
|
||||
args ?? process.env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
config: AwsLambdaBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return async (event) => {
|
||||
const app = await createApp(config, args, opts);
|
||||
const app = await createApp(config, args);
|
||||
return await handle(app.server)(event);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: awsLambda.createApp,
|
||||
// @todo: add a request to lambda event translator?
|
||||
makeHandler: (c, a, o) => async (request: Request) => {
|
||||
const app = await awsLambda.createApp(c, a, o);
|
||||
makeHandler: (c, a) => async (request: Request) => {
|
||||
const app = await awsLambda.createApp(c, a);
|
||||
return app.fetch(request);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import path from "node:path";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from ".";
|
||||
import { config, type App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
|
||||
export async function createApp<Env = BunEnv>(
|
||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
registerLocalMediaAdapter();
|
||||
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
|
||||
...config,
|
||||
},
|
||||
args ?? (process.env as Env),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = BunEnv>(
|
||||
config: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||
app = await createApp(config, args ?? (process.env as Env));
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
|
||||
{
|
||||
distPath,
|
||||
connection,
|
||||
initialConfig,
|
||||
config: _config,
|
||||
options,
|
||||
port = config.server.default_port,
|
||||
onBuilt,
|
||||
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
|
||||
...serveOptions
|
||||
}: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
Bun.serve({
|
||||
...serveOptions,
|
||||
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
|
||||
fetch: createHandler(
|
||||
{
|
||||
connection,
|
||||
initialConfig,
|
||||
config: _config,
|
||||
options,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
|
||||
serveStatic,
|
||||
},
|
||||
args,
|
||||
opts,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { bunSqlite } from "./BunSqliteConnection";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { describe } from "bun:test";
|
||||
import { describe, test, mock, expect } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
describe("BunSqliteConnection", () => {
|
||||
connectionTestSuite(bunTestRunner, {
|
||||
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
|
||||
}),
|
||||
rawDialectDetails: [],
|
||||
});
|
||||
|
||||
test("onCreateConnection", async () => {
|
||||
const called = mock(() => null);
|
||||
|
||||
const conn = bunSqlite({
|
||||
onCreateConnection: (db) => {
|
||||
expect(db).toBeInstanceOf(Database);
|
||||
called();
|
||||
},
|
||||
});
|
||||
await conn.ping();
|
||||
|
||||
expect(conn).toBeInstanceOf(GenericSqliteConnection);
|
||||
expect(conn.db).toBeInstanceOf(Database);
|
||||
expect(called).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
import {
|
||||
genericSqlite,
|
||||
type GenericSqliteConnection,
|
||||
type GenericSqliteConnectionConfig,
|
||||
} from "bknd";
|
||||
import { omitKeys } from "bknd/utils";
|
||||
|
||||
export type BunSqliteConnection = GenericSqliteConnection<Database>;
|
||||
export type BunSqliteConnectionConfig = {
|
||||
database: Database;
|
||||
};
|
||||
export type BunSqliteConnectionConfig = Omit<
|
||||
GenericSqliteConnectionConfig<Database>,
|
||||
"name" | "supports"
|
||||
> &
|
||||
({ database?: Database; url?: never } | { url?: string; database?: never });
|
||||
|
||||
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
|
||||
let db: Database;
|
||||
export function bunSqlite(config?: BunSqliteConnectionConfig) {
|
||||
let db: Database | undefined;
|
||||
if (config) {
|
||||
if ("database" in config) {
|
||||
if ("database" in config && config.database) {
|
||||
db = config.database;
|
||||
} else {
|
||||
} else if (config.url) {
|
||||
db = new Database(config.url);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
db = new Database(":memory:");
|
||||
}
|
||||
|
||||
return genericSqlite("bun-sqlite", db, (utils) => {
|
||||
//const fn = cache ? "query" : "prepare";
|
||||
return genericSqlite(
|
||||
"bun-sqlite",
|
||||
db,
|
||||
(utils) => {
|
||||
const getStmt = (sql: string) => db.prepare(sql);
|
||||
|
||||
return {
|
||||
@@ -36,5 +47,7 @@ export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string })
|
||||
}),
|
||||
close: () => db.close(),
|
||||
};
|
||||
});
|
||||
},
|
||||
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
|
||||
|
||||
export const bunTestRunner = {
|
||||
describe,
|
||||
@@ -8,4 +8,5 @@ export const bunTestRunner = {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
/* beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog); */
|
||||
|
||||
describe("cf adapter", () => {
|
||||
const DB_URL = ":memory:";
|
||||
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
|
||||
const staticConfig = await makeConfig(
|
||||
{
|
||||
connection: { url: DB_URL },
|
||||
initialConfig: { data: { basepath: DB_URL } },
|
||||
config: { data: { basepath: DB_URL } },
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
);
|
||||
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(staticConfig.connection).toBeDefined();
|
||||
|
||||
const dynamicConfig = await makeConfig(
|
||||
{
|
||||
app: (env) => ({
|
||||
initialConfig: { data: { basepath: env.DB_URL } },
|
||||
config: { data: { basepath: env.DB_URL } },
|
||||
connection: { url: env.DB_URL },
|
||||
}),
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
);
|
||||
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(dynamicConfig.connection).toBeDefined();
|
||||
});
|
||||
|
||||
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
||||
makeApp: async (c, a, o) => {
|
||||
return await createApp(c, { env: a } as any, o);
|
||||
makeApp: async (c, a) => {
|
||||
return await createApp(c, { env: a } as any);
|
||||
},
|
||||
makeHandler: (c, a, o) => {
|
||||
makeHandler: (c, a) => {
|
||||
console.log("args", a);
|
||||
return async (request: any) => {
|
||||
const app = await createApp(
|
||||
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
|
||||
connection: { url: DB_URL },
|
||||
},
|
||||
a as any,
|
||||
o,
|
||||
);
|
||||
return app.fetch(request);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { MaybePromise } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||
|
||||
declare global {
|
||||
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
||||
};
|
||||
|
||||
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
ctx: Partial<CloudflareContext<Env>> = {},
|
||||
opts: RuntimeOptions = {
|
||||
// by default, require the app to be rebuilt every time
|
||||
force: true,
|
||||
},
|
||||
) {
|
||||
const appConfig = await makeConfig(
|
||||
{
|
||||
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
|
||||
return await createRuntimeApp<Env>(appConfig, ctx?.env);
|
||||
}
|
||||
|
||||
// compatiblity
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { Miniflare } from "miniflare";
|
||||
import { doSqlite } from "./DoConnection";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
const script = `
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
@@ -40,6 +41,9 @@ export default {
|
||||
}
|
||||
`;
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("doSqlite", async () => {
|
||||
connectionTestSuite(viTestRunner, {
|
||||
makeConnection: async () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
||||
import { Miniflare } from "miniflare";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("cacheWorkersKV", async () => {
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
type CloudflareBkndConfig,
|
||||
type CloudflareEnv,
|
||||
} from "bknd/adapter/cloudflare";
|
||||
import type { PlatformProxy } from "wrangler";
|
||||
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
|
||||
import process from "node:process";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export type WithPlatformProxyOptions = {
|
||||
/**
|
||||
@@ -14,22 +15,27 @@ export type WithPlatformProxyOptions = {
|
||||
* You can override/force this by setting this option.
|
||||
*/
|
||||
useProxy?: boolean;
|
||||
proxyOptions?: GetPlatformProxyOptions;
|
||||
};
|
||||
|
||||
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
config?: CloudflareBkndConfig<Env>,
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
opts?: WithPlatformProxyOptions,
|
||||
) {
|
||||
const use_proxy =
|
||||
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
|
||||
let proxy: PlatformProxy | undefined;
|
||||
|
||||
$console.log("Using cloudflare platform proxy");
|
||||
|
||||
async function getEnv(env?: Env): Promise<Env> {
|
||||
if (use_proxy) {
|
||||
if (!proxy) {
|
||||
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
|
||||
proxy = await getPlatformProxy();
|
||||
setTimeout(proxy?.dispose, 1000);
|
||||
proxy = await getPlatformProxy(opts?.proxyOptions);
|
||||
process.on("exit", () => {
|
||||
proxy?.dispose();
|
||||
});
|
||||
}
|
||||
return proxy.env as unknown as Env;
|
||||
}
|
||||
@@ -50,16 +56,22 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
// @ts-ignore
|
||||
app: async (_env) => {
|
||||
const env = await getEnv(_env);
|
||||
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
||||
|
||||
if (config?.app === undefined && use_proxy) {
|
||||
const binding = getBinding(env, "D1Database");
|
||||
if (config?.app === undefined && use_proxy && binding) {
|
||||
return {
|
||||
connection: d1Sqlite({
|
||||
binding: binding.value,
|
||||
}),
|
||||
};
|
||||
} else if (typeof config?.app === "function") {
|
||||
return config?.app(env);
|
||||
const appConfig = await config?.app(env);
|
||||
if (binding) {
|
||||
appConfig.connection = d1Sqlite({
|
||||
binding: binding.value,
|
||||
}) as any;
|
||||
}
|
||||
return appConfig;
|
||||
}
|
||||
return config?.app || {};
|
||||
},
|
||||
|
||||
@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
|
||||
import { StorageR2Adapter } from "./StorageR2Adapter";
|
||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||
import path from "node:path";
|
||||
import { describe, afterAll, test, expect } from "vitest";
|
||||
import { describe, afterAll, test, expect, beforeAll } from "vitest";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
let mf: Miniflare | undefined;
|
||||
describe("StorageR2Adapter", async () => {
|
||||
|
||||
@@ -24,45 +24,157 @@ export function devFsVitePlugin({
|
||||
projectRoot = config.root;
|
||||
},
|
||||
configureServer(server) {
|
||||
if (!isDev) return;
|
||||
if (!isDev) {
|
||||
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track active chunked requests
|
||||
const activeRequests = new Map<
|
||||
string,
|
||||
{
|
||||
totalChunks: number;
|
||||
filename: string;
|
||||
chunks: string[];
|
||||
receivedChunks: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Intercept stdout to watch for our write requests
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
|
||||
const output = chunk.toString();
|
||||
|
||||
// Check if this output contains our special write request
|
||||
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
|
||||
try {
|
||||
// Extract the JSON from the log line
|
||||
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
|
||||
if (match) {
|
||||
const writeRequest = JSON.parse(match[1]);
|
||||
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
|
||||
if (verbose) {
|
||||
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
|
||||
// Skip our own debug output
|
||||
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
||||
// @ts-ignore
|
||||
// biome-ignore lint/style/noArguments: <explanation>
|
||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||
}
|
||||
|
||||
// Process the write request immediately
|
||||
// Track if we process any protocol messages (to suppress output)
|
||||
let processedProtocolMessage = false;
|
||||
|
||||
// Process all start markers in this output
|
||||
if (output.includes("{{DEV_FS_START}}")) {
|
||||
const startMatches = [
|
||||
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
|
||||
];
|
||||
for (const startMatch of startMatches) {
|
||||
const requestId = startMatch[1];
|
||||
const totalChunks = Number.parseInt(startMatch[2]);
|
||||
const filename = startMatch[3];
|
||||
|
||||
activeRequests.set(requestId, {
|
||||
totalChunks,
|
||||
filename,
|
||||
chunks: new Array(totalChunks),
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
|
||||
);
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// Process all chunk data in this output
|
||||
if (output.includes("{{DEV_FS_CHUNK}}")) {
|
||||
const chunkMatches = [
|
||||
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
|
||||
];
|
||||
for (const chunkMatch of chunkMatches) {
|
||||
const requestId = chunkMatch[1];
|
||||
const chunkIndex = Number.parseInt(chunkMatch[2]);
|
||||
const chunkData = chunkMatch[3];
|
||||
|
||||
const request = activeRequests.get(requestId);
|
||||
if (request) {
|
||||
request.chunks[chunkIndex] = chunkData;
|
||||
request.receivedChunks++;
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
|
||||
);
|
||||
|
||||
// Validate base64 chunk
|
||||
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
|
||||
verbose &&
|
||||
console.warn(
|
||||
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// Process all end markers in this output
|
||||
if (output.includes("{{DEV_FS_END}}")) {
|
||||
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
|
||||
for (const endMatch of endMatches) {
|
||||
const requestId = endMatch[1];
|
||||
const request = activeRequests.get(requestId);
|
||||
|
||||
if (request && request.receivedChunks === request.totalChunks) {
|
||||
try {
|
||||
// Reconstruct the base64 string
|
||||
const fullBase64 = request.chunks.join("");
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
|
||||
);
|
||||
|
||||
// Decode and parse
|
||||
const decodedJson = atob(fullBase64);
|
||||
const writeRequest = JSON.parse(decodedJson);
|
||||
|
||||
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
|
||||
);
|
||||
|
||||
// Process the write request
|
||||
(async () => {
|
||||
try {
|
||||
const fullPath = resolve(projectRoot, writeRequest.filename);
|
||||
verbose &&
|
||||
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
|
||||
await nodeWriteFile(fullPath, writeRequest.data);
|
||||
if (verbose) {
|
||||
verbose &&
|
||||
console.debug("[dev-fs-plugin] File written successfully!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[dev-fs-plugin] Error writing file:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
// Don't output the raw write request to console
|
||||
// Clean up
|
||||
activeRequests.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not a valid write request, continue with normal output
|
||||
console.error(
|
||||
"[dev-fs-plugin] Error processing chunked request:",
|
||||
String(error),
|
||||
);
|
||||
activeRequests.delete(requestId);
|
||||
}
|
||||
} else if (request) {
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
|
||||
);
|
||||
}
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// If we processed any protocol messages, suppress output
|
||||
if (processedProtocolMessage) {
|
||||
return callback ? callback() : true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
|
||||
// @ts-ignore
|
||||
transform(code, id, options) {
|
||||
// Only transform in SSR mode during development
|
||||
if (!isDev || !options?.ssr) return;
|
||||
//if (!isDev || !options?.ssr) return;
|
||||
if (!isDev) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the bknd config file
|
||||
if (id.includes(configFile)) {
|
||||
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.__devFsPolyfill = {
|
||||
writeFile: async (filename, data) => {
|
||||
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""}
|
||||
${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
|
||||
|
||||
// Use console logging as a communication channel
|
||||
// The main process will watch for this specific log pattern
|
||||
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Output as a specially formatted console message
|
||||
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
|
||||
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
|
||||
// Output as a specially formatted console message with end delimiter
|
||||
// Base64 encode the JSON to avoid any control character issues
|
||||
const jsonString = JSON.stringify(writeRequest);
|
||||
const encodedJson = btoa(jsonString);
|
||||
|
||||
// Split into reasonable chunks that balance performance vs reliability
|
||||
const chunkSize = 2000; // 2KB chunks - safe for most environments
|
||||
const chunks = [];
|
||||
for (let i = 0; i < encodedJson.length; i += chunkSize) {
|
||||
chunks.push(encodedJson.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
// Send start marker (use stdout.write to avoid console display)
|
||||
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
|
||||
|
||||
// Send each chunk
|
||||
chunks.forEach((chunk, index) => {
|
||||
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
|
||||
});
|
||||
|
||||
// Send end marker
|
||||
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
}`;
|
||||
return polyfill + code;
|
||||
} else {
|
||||
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
|
||||
}
|
||||
},
|
||||
} satisfies Plugin;
|
||||
|
||||
@@ -13,21 +13,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
|
||||
app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
export type CreateAdapterAppOptions = {
|
||||
force?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
export type FrameworkOptions = CreateAdapterAppOptions;
|
||||
export type RuntimeOptions = CreateAdapterAppOptions;
|
||||
|
||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||
distPath?: string;
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
@@ -41,7 +34,7 @@ export type DefaultArgs = {
|
||||
export async function makeConfig<Args = DefaultArgs>(
|
||||
config: BkndConfig<Args>,
|
||||
args?: Args,
|
||||
): Promise<CreateAppConfig> {
|
||||
): Promise<Omit<BkndConfig<Args>, "app">> {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
const { app, ...rest } = config;
|
||||
if (app) {
|
||||
@@ -59,15 +52,12 @@ export async function makeConfig<Args = DefaultArgs>(
|
||||
}
|
||||
|
||||
// a map that contains all apps by id
|
||||
const apps = new Map<string, App>();
|
||||
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||
config: Config = {} as Config,
|
||||
args?: Args,
|
||||
opts?: CreateAdapterAppOptions,
|
||||
): Promise<App> {
|
||||
const id = opts?.id ?? "app";
|
||||
let app = apps.get(id);
|
||||
if (!app || opts?.force) {
|
||||
await config.beforeBuild?.(undefined, $registries);
|
||||
|
||||
const appConfig = await makeConfig(config, args);
|
||||
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||
let connection: Connection | undefined;
|
||||
@@ -82,22 +72,14 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
||||
appConfig.connection = connection;
|
||||
}
|
||||
|
||||
app = App.create(appConfig);
|
||||
|
||||
if (!opts?.force) {
|
||||
apps.set(id, app);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
return App.create(appConfig);
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||
config: FrameworkBkndConfig = {},
|
||||
args?: Args,
|
||||
opts?: FrameworkOptions,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args, opts);
|
||||
const app = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
if (config.onBuilt) {
|
||||
@@ -120,9 +102,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
|
||||
export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args, opts);
|
||||
const app = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
app.emgr.onEvent(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
|
||||
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
||||
import { isNode } from "bknd/utils";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
export async function getApp<Env = NextjsEnv>(
|
||||
config: NextjsBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
|
||||
return await createFrameworkApp(config, args ?? (process.env as Env));
|
||||
}
|
||||
|
||||
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
export function serve<Env = NextjsEnv>(
|
||||
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
return async (req: Request) => {
|
||||
const app = await getApp(config, args, opts);
|
||||
const app = await getApp(config, args);
|
||||
const request = getCleanRequest(req, cleanRequest);
|
||||
return app.fetch(request);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { genericSqlite } from "bknd";
|
||||
import {
|
||||
genericSqlite,
|
||||
type GenericSqliteConnection,
|
||||
type GenericSqliteConnectionConfig,
|
||||
} from "bknd";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { omitKeys } from "bknd/utils";
|
||||
|
||||
export type NodeSqliteConnectionConfig = {
|
||||
database: DatabaseSync;
|
||||
};
|
||||
export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
|
||||
export type NodeSqliteConnectionConfig = Omit<
|
||||
GenericSqliteConnectionConfig<DatabaseSync>,
|
||||
"name" | "supports"
|
||||
> &
|
||||
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
|
||||
|
||||
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
|
||||
let db: DatabaseSync;
|
||||
export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
|
||||
let db: DatabaseSync | undefined;
|
||||
if (config) {
|
||||
if ("database" in config) {
|
||||
if ("database" in config && config.database) {
|
||||
db = config.database;
|
||||
} else {
|
||||
} else if (config.url) {
|
||||
db = new DatabaseSync(config.url);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
db = new DatabaseSync(":memory:");
|
||||
}
|
||||
|
||||
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
|
||||
"node-sqlite",
|
||||
db,
|
||||
(utils) => {
|
||||
const getStmt = (sql: string) => {
|
||||
const stmt = db.prepare(sql);
|
||||
//stmt.setReadBigInts(true);
|
||||
return stmt;
|
||||
};
|
||||
const getStmt = (sql: string) => db.prepare(sql);
|
||||
|
||||
return {
|
||||
db,
|
||||
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
|
||||
};
|
||||
},
|
||||
{
|
||||
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
|
||||
supports: {
|
||||
batching: false,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { nodeSqlite } from "./NodeSqliteConnection";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { describe } from "vitest";
|
||||
import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
|
||||
import { viTestRunner } from "../vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("NodeSqliteConnection", () => {
|
||||
connectionTestSuite(viTestRunner, {
|
||||
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
|
||||
}),
|
||||
rawDialectDetails: [],
|
||||
});
|
||||
|
||||
test("onCreateConnection", async () => {
|
||||
const called = vi.fn(() => null);
|
||||
|
||||
const conn = nodeSqlite({
|
||||
onCreateConnection: (db) => {
|
||||
expect(db).toBeInstanceOf(DatabaseSync);
|
||||
called();
|
||||
},
|
||||
});
|
||||
await conn.ping();
|
||||
|
||||
expect(conn).toBeInstanceOf(GenericSqliteConnection);
|
||||
expect(conn.db).toBeInstanceOf(DatabaseSync);
|
||||
expect(called).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { registerLocalMediaAdapter } from "adapter/node/storage";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { config as $config, type App } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||
export async function createApp<Env = NodeEnv>(
|
||||
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
|
||||
},
|
||||
// @ts-ignore
|
||||
args ?? { env: process.env },
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = NodeEnv>(
|
||||
config: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||
app = await createApp(config, args ?? (process.env as Env));
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
|
||||
export function serve<Env = NodeEnv>(
|
||||
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
honoServe(
|
||||
{
|
||||
port,
|
||||
hostname,
|
||||
fetch: createHandler(config, args, opts),
|
||||
fetch: createHandler(config, args),
|
||||
},
|
||||
(connInfo) => {
|
||||
$console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||
|
||||
@@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest";
|
||||
import * as node from "./node.adapter";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("node adapter", () => {
|
||||
adapterTestSuite(viTestRunner, {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe } from "vitest";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { StorageLocalAdapter } from "adapter/node";
|
||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("StorageLocalAdapter (node)", async () => {
|
||||
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import nodeAssert from "node:assert/strict";
|
||||
import { test, describe, beforeEach, afterEach } from "node:test";
|
||||
import { test, describe, beforeEach, afterEach, after, before } from "node:test";
|
||||
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
|
||||
|
||||
// Track mock function calls
|
||||
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
|
||||
}),
|
||||
beforeEach: beforeEach,
|
||||
afterEach: afterEach,
|
||||
afterAll: () => {},
|
||||
afterAll: after,
|
||||
beforeAll: before,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TestFn, TestRunner, Test } from "core/test";
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
|
||||
|
||||
function vitestTest(label: string, fn: TestFn, options?: any) {
|
||||
return test(label, fn as any);
|
||||
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
|
||||
beforeEach: beforeEach,
|
||||
afterEach: afterEach,
|
||||
afterAll: afterAll,
|
||||
beforeAll: beforeAll,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
||||
describe("react-router adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: rr.getApp,
|
||||
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
|
||||
makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import type { FrameworkOptions } from "adapter";
|
||||
|
||||
type ReactRouterEnv = NodeJS.ProcessEnv;
|
||||
type ReactRouterFunctionArgs = {
|
||||
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
|
||||
export async function getApp<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? process.env, opts);
|
||||
return await createFrameworkApp(config, args ?? process.env);
|
||||
}
|
||||
|
||||
export function serve<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
return async (fnArgs: ReactRouterFunctionArgs) => {
|
||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
async function createApp<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
env: ViteEnv = {} as ViteEnv,
|
||||
opts: FrameworkOptions = {},
|
||||
): Promise<App> {
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
|
||||
],
|
||||
},
|
||||
env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function serve<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
args?: ViteEnv,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = await createApp(config, env, opts);
|
||||
const app = await createApp(config, env);
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -278,7 +278,9 @@ export class Authenticator<
|
||||
}
|
||||
|
||||
return payload as any;
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
$console.debug("Authenticator jwt verify error", String(e));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -396,8 +398,9 @@ export class Authenticator<
|
||||
if (headers.has("Authorization")) {
|
||||
const bearerHeader = String(headers.get("Authorization"));
|
||||
token = bearerHeader.replace("Bearer ", "");
|
||||
} else if (is_context) {
|
||||
token = await this.getAuthCookie(c as Context);
|
||||
} else {
|
||||
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
||||
token = await this.getAuthCookie(context);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeAppFromEnv } from "cli/commands/run";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import c from "picocolors";
|
||||
import { withConfigOptions } from "cli/utils/options";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export const config: CliCommand = (program) => {
|
||||
withConfigOptions(program.command("config"))
|
||||
@@ -19,7 +20,14 @@ export const config: CliCommand = (program) => {
|
||||
config = getDefaultConfig();
|
||||
} else {
|
||||
const app = await makeAppFromEnv(options);
|
||||
config = app.toJSON(options.secrets);
|
||||
const manager = app.modules;
|
||||
|
||||
if (options.secrets) {
|
||||
$console.warn("Including secrets in output");
|
||||
config = manager.toJSON(true);
|
||||
} else {
|
||||
config = manager.extractSecrets().configs;
|
||||
}
|
||||
}
|
||||
|
||||
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
|
||||
@@ -31,5 +39,7 @@ export const config: CliCommand = (program) => {
|
||||
} else {
|
||||
console.info(JSON.parse(config));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -34,4 +34,5 @@ async function action(options: { out?: string; clean?: boolean }) {
|
||||
|
||||
// biome-ignore lint/suspicious/noConsoleLog:
|
||||
console.log(c.green(`Assets copied to: ${c.bold(out)}`));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ const subjects = {
|
||||
async function action(subject: string) {
|
||||
if (subject in subjects) {
|
||||
await subjects[subject]();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("Invalid subject: ", subject);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { copyAssets } from "./copy-assets";
|
||||
export { types } from "./types";
|
||||
export { mcp } from "./mcp/mcp";
|
||||
export { sync } from "./sync";
|
||||
export { secrets } from "./secrets";
|
||||
|
||||
@@ -9,18 +9,28 @@ export const PLATFORMS = ["node", "bun"] as const;
|
||||
export type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
|
||||
const onNotFound = (path: string) => {
|
||||
$console.debug("Couldn't resolve static file at", path);
|
||||
};
|
||||
|
||||
switch (server) {
|
||||
case "node": {
|
||||
const m = await import("@hono/node-server/serve-static");
|
||||
const root = getRelativeDistPath() + "/static";
|
||||
$console.log("Serving static files from", root);
|
||||
return m.serveStatic({
|
||||
// somehow different for node
|
||||
root: getRelativeDistPath() + "/static",
|
||||
root,
|
||||
onNotFound,
|
||||
});
|
||||
}
|
||||
case "bun": {
|
||||
const m = await import("hono/bun");
|
||||
const root = path.resolve(getRelativeDistPath(), "static");
|
||||
$console.log("Serving static files from", root);
|
||||
return m.serveStatic({
|
||||
root: path.resolve(getRelativeDistPath(), "static"),
|
||||
root,
|
||||
onNotFound,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Config } from "@libsql/client/node";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config, type App, type CreateAppConfig } from "bknd";
|
||||
import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
@@ -60,7 +60,7 @@ type MakeAppConfig = {
|
||||
connection?: CreateAppConfig["connection"];
|
||||
server?: { platform?: Platform };
|
||||
setAdminHtml?: boolean;
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
};
|
||||
|
||||
async function makeApp(config: MakeAppConfig) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => {
|
||||
.option("--pretty", "pretty print")
|
||||
.action((options) => {
|
||||
const schema = getDefaultSchema();
|
||||
// biome-ignore lint/suspicious/noConsoleLog:
|
||||
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
|
||||
console.info(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CliCommand } from "../types";
|
||||
import { makeAppFromEnv } from "cli/commands/run";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import c from "picocolors";
|
||||
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||
import { transformObject } from "bknd/utils";
|
||||
import { Option } from "commander";
|
||||
|
||||
export const secrets: CliCommand = (program) => {
|
||||
withConfigOptions(program.command("secrets"))
|
||||
.description("get app secrets")
|
||||
.option("--template", "template output without the actual secrets")
|
||||
.addOption(
|
||||
new Option("--format <format>", "format output").choices(["json", "env"]).default("json"),
|
||||
)
|
||||
.option("--out <file>", "output file")
|
||||
.action(
|
||||
async (
|
||||
options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>,
|
||||
) => {
|
||||
const app = await makeAppFromEnv(options);
|
||||
const manager = app.modules;
|
||||
|
||||
let secrets = manager.extractSecrets().secrets;
|
||||
if (options.template) {
|
||||
secrets = transformObject(secrets, () => "");
|
||||
}
|
||||
|
||||
console.info("");
|
||||
if (options.out) {
|
||||
if (options.format === "env") {
|
||||
await writeFile(
|
||||
options.out,
|
||||
Object.entries(secrets)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n"),
|
||||
);
|
||||
} else {
|
||||
await writeFile(options.out, JSON.stringify(secrets, null, 2));
|
||||
}
|
||||
console.info(`Secrets written to ${c.cyan(options.out)}`);
|
||||
} else {
|
||||
if (options.format === "env") {
|
||||
console.info(
|
||||
c.cyan(
|
||||
Object.entries(secrets)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.info(secrets);
|
||||
}
|
||||
}
|
||||
console.info("");
|
||||
process.exit(0);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -7,13 +7,14 @@ import { withConfigOptions } from "cli/utils/options";
|
||||
export const sync: CliCommand = (program) => {
|
||||
withConfigOptions(program.command("sync"))
|
||||
.description("sync database")
|
||||
.option("--dump", "dump operations to console instead of executing them")
|
||||
.option("--force", "perform database syncing operations")
|
||||
.option("--drop", "include destructive DDL operations")
|
||||
.option("--out <file>", "output file")
|
||||
.option("--sql", "use sql output")
|
||||
.action(async (options) => {
|
||||
const app = await makeAppFromEnv(options);
|
||||
const schema = app.em.schema();
|
||||
console.info(c.dim("Checking database state..."));
|
||||
const stmts = await schema.sync({ drop: options.drop });
|
||||
|
||||
console.info("");
|
||||
@@ -24,22 +25,27 @@ export const sync: CliCommand = (program) => {
|
||||
// @todo: currently assuming parameters aren't used
|
||||
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
||||
|
||||
if (options.dump) {
|
||||
if (options.force) {
|
||||
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
|
||||
await schema.sync({ force: true, drop: options.drop });
|
||||
|
||||
console.info(`\n${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`${c.green("Database synced")}`);
|
||||
} else {
|
||||
if (options.out) {
|
||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||
await writeFile(options.out, output);
|
||||
console.info(`SQL written to ${c.cyan(options.out)}`);
|
||||
} else {
|
||||
console.info(options.sql ? c.cyan(sql) : stmts);
|
||||
}
|
||||
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
|
||||
|
||||
console.info(
|
||||
c.yellow(
|
||||
"\nNo statements have been executed. Use --force to perform database syncing operations",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await schema.sync({ force: true, drop: options.drop });
|
||||
console.info(c.cyan(sql));
|
||||
|
||||
console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`${c.green("Database synced")}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -35,4 +35,6 @@ async function action({
|
||||
await writeFile(outfile, et.toString());
|
||||
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -78,9 +78,11 @@ async function create(app: App, options: any) {
|
||||
password: await strategy.hash(password as string),
|
||||
});
|
||||
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
$log.error("Error creating user");
|
||||
$console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +123,10 @@ async function update(app: App, options: any) {
|
||||
|
||||
if (await app.module.auth.changePassword(user.id, password)) {
|
||||
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
$log.error("Error updating user");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,4 +162,5 @@ async function token(app: App, options: any) {
|
||||
console.log(
|
||||
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ export interface AppEntity<IdType = number | string> {
|
||||
|
||||
export interface DB {
|
||||
// make sure to make unknown as "any"
|
||||
[key: string]: {
|
||||
/* [key: string]: {
|
||||
id: PrimaryFieldType;
|
||||
[key: string]: any;
|
||||
};
|
||||
}; */
|
||||
// @todo: that's not good, but required for admin options
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -31,6 +31,7 @@ export type TestRunner = {
|
||||
beforeEach: (fn: () => MaybePromise<void>) => void;
|
||||
afterEach: (fn: () => MaybePromise<void>) => void;
|
||||
afterAll: (fn: () => MaybePromise<void>) => void;
|
||||
beforeAll: (fn: () => MaybePromise<void>) => void;
|
||||
};
|
||||
|
||||
export async function retry<T>(
|
||||
|
||||
@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||
|
||||
@@ -396,6 +396,38 @@ export function getPath(
|
||||
}
|
||||
}
|
||||
|
||||
export function setPath(object: object, _path: string | (string | number)[], value: any) {
|
||||
let path = _path;
|
||||
// Optional string-path support.
|
||||
// You can remove this `if` block if you don't need it.
|
||||
if (typeof path === "string") {
|
||||
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
|
||||
path = path
|
||||
.split(/[.\[\]]+/)
|
||||
.filter((x) => x)
|
||||
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
|
||||
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
|
||||
}
|
||||
|
||||
if (path.length === 0) {
|
||||
throw new Error("The path must have at least one entry in it");
|
||||
}
|
||||
|
||||
const [head, ...tail] = path as any;
|
||||
|
||||
if (tail.length === 0) {
|
||||
object[head] = value;
|
||||
return object;
|
||||
}
|
||||
|
||||
if (!(head in object)) {
|
||||
object[head] = typeof tail[0] === "number" ? [] : {};
|
||||
}
|
||||
|
||||
setPath(object[head], tail, value);
|
||||
return object;
|
||||
}
|
||||
|
||||
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
|
||||
const nl = indent ? "\n" : "";
|
||||
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
|
||||
|
||||
@@ -38,7 +38,8 @@ export class InvalidSchemaError extends Error {
|
||||
) {
|
||||
super(
|
||||
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}`,
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}\n\n` +
|
||||
`Schema: ${JSON.stringify(schema.toJSON(), null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const _oldConsoles = {
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
let _oldStderr: any;
|
||||
let _oldStdout: any;
|
||||
|
||||
export async function withDisabledConsole<R>(
|
||||
fn: () => Promise<R>,
|
||||
@@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
// Disable stderr
|
||||
_oldStderr = process.stderr.write;
|
||||
_oldStdout = process.stdout.write;
|
||||
process.stderr.write = () => true;
|
||||
process.stdout.write = () => true;
|
||||
$console?.setLevel("critical");
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
process.stderr.write = _oldStderr;
|
||||
process.stdout.write = _oldStdout;
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
|
||||
@@ -42,6 +42,9 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
type T = RepositoryResultJSON<Data>;
|
||||
|
||||
// @todo: if none found, still returns meta...
|
||||
|
||||
return this.readMany(entity, {
|
||||
...query,
|
||||
limit: 1,
|
||||
|
||||
@@ -60,6 +60,7 @@ export class DataController extends Controller {
|
||||
"/sync",
|
||||
permission(DataPermissions.databaseSync),
|
||||
mcpTool("data_sync", {
|
||||
// @todo: should be removed if readonly
|
||||
annotations: {
|
||||
destructiveHint: true,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getPath } from "bknd/utils";
|
||||
import * as proto from "data/prototype";
|
||||
import { createApp } from "App";
|
||||
import type { MaybePromise } from "core/types";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
// @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc.
|
||||
// @todo: add toDriver/fromDriver tests on all types and fields
|
||||
@@ -21,7 +22,9 @@ export function connectionTestSuite(
|
||||
rawDialectDetails: string[];
|
||||
},
|
||||
) {
|
||||
const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner;
|
||||
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("base", () => {
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
@@ -247,7 +250,7 @@ export function connectionTestSuite(
|
||||
|
||||
const app = createApp({
|
||||
connection: ctx.connection,
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
@@ -333,7 +336,7 @@ export function connectionTestSuite(
|
||||
|
||||
const app = createApp({
|
||||
connection: ctx.connection,
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { KyselyPlugin, QueryResult } from "kysely";
|
||||
import {
|
||||
type IGenericSqlite,
|
||||
type OnCreateConnection,
|
||||
type Promisable,
|
||||
parseBigInt,
|
||||
buildQueryFn,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
} from "kysely-generic-sqlite";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import type { ConnQuery, ConnQueryResults, Features } from "../Connection";
|
||||
import type { MaybePromise } from "bknd";
|
||||
|
||||
export type { IGenericSqlite };
|
||||
export type TStatement = { sql: string; parameters?: any[] | readonly any[] };
|
||||
@@ -16,11 +16,11 @@ export interface IGenericCustomSqlite<DB = unknown> extends IGenericSqlite<DB> {
|
||||
batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>;
|
||||
}
|
||||
|
||||
export type GenericSqliteConnectionConfig = {
|
||||
export type GenericSqliteConnectionConfig<Database = unknown> = {
|
||||
name?: string;
|
||||
additionalPlugins?: KyselyPlugin[];
|
||||
excludeTables?: string[];
|
||||
onCreateConnection?: OnCreateConnection;
|
||||
onCreateConnection?: (db: Database) => MaybePromise<void>;
|
||||
supports?: Partial<Features>;
|
||||
};
|
||||
|
||||
@@ -35,7 +35,12 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
|
||||
) {
|
||||
super({
|
||||
dialect: GenericSqliteDialect,
|
||||
dialectArgs: [executor, config?.onCreateConnection],
|
||||
dialectArgs: [
|
||||
executor,
|
||||
config?.onCreateConnection && typeof config.onCreateConnection === "function"
|
||||
? (c: any) => config.onCreateConnection?.(c.db.db as any)
|
||||
: undefined,
|
||||
],
|
||||
additionalPlugins: config?.additionalPlugins,
|
||||
excludeTables: config?.excludeTables,
|
||||
});
|
||||
@@ -61,7 +66,6 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
|
||||
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
|
||||
const executor = await this.getExecutor();
|
||||
if (!executor.batch) {
|
||||
//$console.debug("Batching is not supported by this database");
|
||||
return super.executeQueries(...qbs);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const entityConfigSchema = s
|
||||
sort_field: s.string({ default: config.data.default_primary_field }),
|
||||
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
||||
primary_format: s.string({ enum: primaryFieldTypes }),
|
||||
fields_order: s.array(s.string()).optional(),
|
||||
},
|
||||
{ default: {} },
|
||||
)
|
||||
@@ -137,7 +138,9 @@ export class Entity<
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
||||
field.isFillable(context),
|
||||
);
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
@@ -189,9 +192,24 @@ export class Entity<
|
||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
getFields(opts?: { virtual?: boolean; sorted?: boolean }): Field[] {
|
||||
const fields = [...this.fields];
|
||||
const sort = opts?.sorted !== false;
|
||||
const fields_order = this.config.fields_order;
|
||||
if (fields_order && fields_order.length > 0 && sort) {
|
||||
if (fields_order.length !== this.fields.length) {
|
||||
$console.warn("Fields order must contain all fields");
|
||||
} else {
|
||||
// sort fields by order
|
||||
fields.sort((a, b) => {
|
||||
const a_index = fields_order.indexOf(a.name);
|
||||
const b_index = fields_order.indexOf(b.name);
|
||||
return a_index - b_index;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (opts?.virtual) return fields;
|
||||
return fields.filter((f) => !f.isVirtual());
|
||||
}
|
||||
|
||||
addField(field: Field) {
|
||||
@@ -275,7 +293,7 @@ export class Entity<
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
fields = this.getFields({ virtual: true });
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
@@ -309,7 +327,12 @@ export class Entity<
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
|
||||
fields: Object.fromEntries(
|
||||
this.getFields({ virtual: true, sorted: true }).map((field) => [
|
||||
field.name,
|
||||
field.toJSON(),
|
||||
]),
|
||||
),
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class EntityTypescript {
|
||||
indentWidth: 2,
|
||||
indentChar: " ",
|
||||
entityCommentMultiline: true,
|
||||
fieldCommentMultiline: false,
|
||||
fieldCommentMultiline: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class EntityTypescript {
|
||||
}
|
||||
|
||||
typeName(name: string) {
|
||||
return autoFormatString(name);
|
||||
return autoFormatString(name).replace(/ /g, "");
|
||||
}
|
||||
|
||||
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
|
||||
|
||||
@@ -72,12 +72,12 @@ export class Result<T = unknown> {
|
||||
return this.first().parameters;
|
||||
}
|
||||
|
||||
get data() {
|
||||
get data(): T {
|
||||
if (this.options.single) {
|
||||
return this.first().data?.[0];
|
||||
}
|
||||
|
||||
return this.first().data ?? [];
|
||||
return this.first().data ?? ([] as T);
|
||||
}
|
||||
|
||||
async execute(qb: Compilable | Compilable[]) {
|
||||
|
||||
@@ -4,5 +4,6 @@ export * from "./mutation/Mutator";
|
||||
export * from "./query/Repository";
|
||||
export * from "./query/WhereBuilder";
|
||||
export * from "./query/WithBuilder";
|
||||
export * from "./Result";
|
||||
export * from "./query/RepositoryResult";
|
||||
export * from "./mutation/MutatorResult";
|
||||
|
||||
@@ -15,17 +15,6 @@ export class EntityIndex {
|
||||
throw new Error("All fields must be instances of Field");
|
||||
}
|
||||
|
||||
if (unique) {
|
||||
const firstRequired = fields[0]?.isRequired();
|
||||
if (!firstRequired) {
|
||||
throw new Error(
|
||||
`Unique indices must have first field as required: ${fields
|
||||
.map((f) => f.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
this.name = [
|
||||
unique ? "idx_unique" : "idx",
|
||||
|
||||
@@ -180,8 +180,15 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
|
||||
override getName(): string {
|
||||
return [
|
||||
super.getName(),
|
||||
[this.connectionEntity.name, this.connectionTableMappedName].filter(Boolean),
|
||||
...Array.from(
|
||||
new Set(
|
||||
[
|
||||
this.type().replace(":", ""),
|
||||
this.connectionEntity.name,
|
||||
this.connectionTableMappedName,
|
||||
].filter(Boolean),
|
||||
),
|
||||
),
|
||||
].join("_");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import { s } from "bknd/utils";
|
||||
import type { PrimaryFieldType } from "bknd";
|
||||
|
||||
export type PolymorphicRelationConfig = s.Static<typeof PolymorphicRelation.schema>;
|
||||
|
||||
@@ -70,7 +71,7 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
override getReferenceQuery(entity: Entity, id: PrimaryFieldType): Partial<RepoQuery> {
|
||||
const info = this.queryInfo(entity);
|
||||
|
||||
return {
|
||||
|
||||
@@ -76,7 +76,7 @@ export class SchemaManager {
|
||||
}
|
||||
|
||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||
const fields = entity.getFields(false);
|
||||
const fields = entity.getFields({ virtual: false, sorted: true });
|
||||
const indices = this.em.getIndicesOf(entity);
|
||||
|
||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
type InitialModuleConfigs,
|
||||
ModuleManagerEvents,
|
||||
} from "./modules/ModuleManager";
|
||||
export type * from "modules/ModuleApi";
|
||||
|
||||
export type { ServerEnv } from "modules/Controller";
|
||||
export type { BkndConfig } from "bknd/adapter";
|
||||
@@ -115,6 +116,7 @@ export {
|
||||
genericSqlite,
|
||||
genericSqliteUtils,
|
||||
type GenericSqliteConnection,
|
||||
type GenericSqliteConnectionConfig,
|
||||
} from "data/connection/sqlite/GenericSqliteConnection";
|
||||
export {
|
||||
EntityTypescript,
|
||||
@@ -128,6 +130,7 @@ export type { EntityRelation } from "data/relations";
|
||||
export type * from "data/entities/Entity";
|
||||
export type { EntityManager } from "data/entities/EntityManager";
|
||||
export type { SchemaManager } from "data/schema/SchemaManager";
|
||||
export type * from "data/entities";
|
||||
export {
|
||||
BaseIntrospector,
|
||||
Connection,
|
||||
|
||||
@@ -181,16 +181,14 @@ export class MediaController extends Controller {
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: s.number(),
|
||||
id: s.anyOf([s.number(), s.string()]),
|
||||
field: s.string(),
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||
async (c) => {
|
||||
const entity_name = c.req.param("entity");
|
||||
const field_name = c.req.param("field");
|
||||
const entity_id = Number.parseInt(c.req.param("id"));
|
||||
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
|
||||
|
||||
// check if entity exists
|
||||
const entity = this.media.em.entity(entity_name);
|
||||
|
||||
@@ -9,6 +9,6 @@ export const mediaFields = {
|
||||
etag: text(),
|
||||
modified_at: datetime(),
|
||||
reference: text(),
|
||||
entity_id: number(),
|
||||
entity_id: text(),
|
||||
metadata: json(),
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export class Storage implements EmitsEvents {
|
||||
...dim,
|
||||
};
|
||||
} catch (e) {
|
||||
$console.warn("Failed to get image dimensions", e);
|
||||
$console.warn("Failed to get image dimensions", e, file);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { hash, pickHeaders, s, parse } from "bknd/utils";
|
||||
import { hash, pickHeaders, s, parse, secret } from "bknd/utils";
|
||||
import type { FileBody, FileListObject, FileMeta } from "../../Storage";
|
||||
import { StorageAdapter } from "../../StorageAdapter";
|
||||
|
||||
export const cloudinaryAdapterConfig = s.object(
|
||||
{
|
||||
cloud_name: s.string(),
|
||||
api_key: s.string(),
|
||||
api_secret: s.string(),
|
||||
api_key: secret(),
|
||||
api_secret: secret(),
|
||||
upload_preset: s.string().optional(),
|
||||
},
|
||||
{ title: "Cloudinary", description: "Cloudinary media storage" },
|
||||
|
||||
@@ -8,15 +8,15 @@ import type {
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { AwsClient } from "core/clients/aws/AwsClient";
|
||||
import { isDebug } from "core/env";
|
||||
import { isFile, pickHeaders2, parse, s } from "bknd/utils";
|
||||
import { isFile, pickHeaders2, parse, s, secret } from "bknd/utils";
|
||||
import { transform } from "lodash-es";
|
||||
import type { FileBody, FileListObject } from "../../Storage";
|
||||
import { StorageAdapter } from "../../StorageAdapter";
|
||||
|
||||
export const s3AdapterConfig = s.object(
|
||||
{
|
||||
access_key: s.string(),
|
||||
secret_access_key: s.string(),
|
||||
access_key: secret(),
|
||||
secret_access_key: secret(),
|
||||
url: s.string({
|
||||
pattern: "^https?://(?:.*)?[^/.]+$",
|
||||
description: "URL to S3 compatible endpoint without trailing slash",
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
|
||||
import { objectEach, transformObject, McpServer, type s, SecretSchema, setPath } from "bknd/utils";
|
||||
import { DebugLogger } from "core/utils/DebugLogger";
|
||||
import { Guard } from "auth/authorize/Guard";
|
||||
import { env } from "core/env";
|
||||
import { BkndError } from "core/errors";
|
||||
import { EventManager, Event } from "core/events";
|
||||
import * as $diff from "core/object/diff";
|
||||
import type { Connection } from "data/connection";
|
||||
import { EntityManager } from "data/entities/EntityManager";
|
||||
import * as proto from "data/prototype";
|
||||
import { TransformPersistFailedException } from "data/errors";
|
||||
import { Hono } from "hono";
|
||||
import type { Kysely } from "kysely";
|
||||
import { mergeWith } from "lodash-es";
|
||||
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
|
||||
import { AppServer } from "modules/server/AppServer";
|
||||
import { AppAuth } from "../auth/AppAuth";
|
||||
import { AppData } from "../data/AppData";
|
||||
import { AppFlows } from "../flows/AppFlows";
|
||||
import { AppMedia } from "../media/AppMedia";
|
||||
import type { ServerEnv } from "./Controller";
|
||||
import { Module, type ModuleBuildContext } from "./Module";
|
||||
import { ModuleHelper } from "./ModuleHelper";
|
||||
import { AppServer } from "modules/server/AppServer";
|
||||
import { AppAuth } from "auth/AppAuth";
|
||||
import { AppData } from "data/AppData";
|
||||
import { AppFlows } from "flows/AppFlows";
|
||||
import { AppMedia } from "media/AppMedia";
|
||||
import type { PartialRec } from "core/types";
|
||||
import { mergeWith, pick } from "lodash-es";
|
||||
|
||||
export type { ModuleBuildContext };
|
||||
|
||||
@@ -47,13 +42,8 @@ export type ModuleSchemas = {
|
||||
export type ModuleConfigs = {
|
||||
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
|
||||
};
|
||||
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||
|
||||
export type InitialModuleConfigs =
|
||||
| ({
|
||||
version: number;
|
||||
} & ModuleConfigs)
|
||||
| PartialRec<ModuleConfigs>;
|
||||
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
|
||||
|
||||
enum Verbosity {
|
||||
silent = 0,
|
||||
@@ -80,42 +70,14 @@ export type ModuleManagerOptions = {
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
// called right after modules are built, before finish
|
||||
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
// whether to store secrets in the database
|
||||
storeSecrets?: boolean;
|
||||
// provided secrets
|
||||
secrets?: Record<string, any>;
|
||||
/** @deprecated */
|
||||
verbosity?: Verbosity;
|
||||
};
|
||||
|
||||
export type ConfigTable<Json = ModuleConfigs> = {
|
||||
id?: number;
|
||||
version: number;
|
||||
type: "config" | "diff" | "backup";
|
||||
json: Json;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
};
|
||||
|
||||
const configJsonSchema = s.anyOf([
|
||||
getDefaultSchema(),
|
||||
s.array(
|
||||
s.strictObject({
|
||||
t: s.string({ enum: ["a", "r", "e"] }),
|
||||
p: s.array(s.anyOf([s.string(), s.number()])),
|
||||
o: s.any().optional(),
|
||||
n: s.any().optional(),
|
||||
}),
|
||||
),
|
||||
]);
|
||||
export const __bknd = proto.entity(TABLE_NAME, {
|
||||
version: proto.number().required(),
|
||||
type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(),
|
||||
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
|
||||
created_at: proto.datetime(),
|
||||
updated_at: proto.datetime(),
|
||||
});
|
||||
type ConfigTable2 = proto.Schema<typeof __bknd>;
|
||||
interface T_INTERNAL_EM {
|
||||
__bknd: ConfigTable2;
|
||||
}
|
||||
|
||||
const debug_modules = env("modules_debug");
|
||||
|
||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||
@@ -127,8 +89,14 @@ export class ModuleManagerConfigUpdateEvent<
|
||||
}> {
|
||||
static override slug = "mm-config-update";
|
||||
}
|
||||
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
|
||||
secrets: Record<string, any>;
|
||||
}> {
|
||||
static override slug = "mm-secrets-extracted";
|
||||
}
|
||||
export const ModuleManagerEvents = {
|
||||
ModuleManagerConfigUpdateEvent,
|
||||
ModuleManagerSecretsExtractedEvent,
|
||||
};
|
||||
|
||||
// @todo: cleanup old diffs on upgrade
|
||||
@@ -137,8 +105,6 @@ export class ModuleManager {
|
||||
static Events = ModuleManagerEvents;
|
||||
|
||||
protected modules: Modules;
|
||||
// internal em for __bknd config table
|
||||
__em!: EntityManager<T_INTERNAL_EM>;
|
||||
// ctx for modules
|
||||
em!: EntityManager;
|
||||
server!: Hono<ServerEnv>;
|
||||
@@ -146,42 +112,24 @@ export class ModuleManager {
|
||||
guard!: Guard;
|
||||
mcp!: ModuleBuildContext["mcp"];
|
||||
|
||||
private _version: number = 0;
|
||||
private _built = false;
|
||||
private readonly _booted_with?: "provided" | "partial";
|
||||
private _stable_configs: ModuleConfigs | undefined;
|
||||
protected _built = false;
|
||||
|
||||
private logger: DebugLogger;
|
||||
protected logger: DebugLogger;
|
||||
|
||||
constructor(
|
||||
private readonly connection: Connection,
|
||||
private options?: Partial<ModuleManagerOptions>,
|
||||
protected readonly connection: Connection,
|
||||
protected options?: Partial<ModuleManagerOptions>,
|
||||
) {
|
||||
this.__em = new EntityManager([__bknd], this.connection);
|
||||
this.modules = {} as Modules;
|
||||
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
||||
this.logger = new DebugLogger(debug_modules);
|
||||
let initial = {} as Partial<ModuleConfigs>;
|
||||
|
||||
if (options?.initial) {
|
||||
if ("version" in options.initial) {
|
||||
const { version, ...initialConfig } = options.initial;
|
||||
this._version = version;
|
||||
initial = stripMark(initialConfig);
|
||||
|
||||
this._booted_with = "provided";
|
||||
} else {
|
||||
initial = mergeWith(getDefaultConfig(), options.initial);
|
||||
this._booted_with = "partial";
|
||||
}
|
||||
this.createModules(options?.initial ?? {});
|
||||
}
|
||||
|
||||
this.logger.log("booted with", this._booted_with);
|
||||
protected onModuleConfigUpdated(key: string, config: any) {}
|
||||
|
||||
this.createModules(initial);
|
||||
}
|
||||
|
||||
private createModules(initial: Partial<ModuleConfigs>) {
|
||||
private createModules(initial: PartialRec<ModuleConfigs>) {
|
||||
this.logger.context("createModules").log("creating modules");
|
||||
try {
|
||||
const context = this.ctx(true);
|
||||
@@ -211,46 +159,7 @@ export class ModuleManager {
|
||||
return this._built;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is set through module's setListener
|
||||
* It's called everytime a module's config is updated in SchemaObject
|
||||
* Needs to rebuild modules and save to database
|
||||
*/
|
||||
private async onModuleConfigUpdated(key: string, config: any) {
|
||||
if (this.options?.onUpdated) {
|
||||
await this.options.onUpdated(key as any, config);
|
||||
} else {
|
||||
await this.buildModules();
|
||||
}
|
||||
}
|
||||
|
||||
private repo() {
|
||||
return this.__em.repo(__bknd, {
|
||||
// to prevent exceptions when table doesn't exist
|
||||
silent: true,
|
||||
// disable counts for performance and compatibility
|
||||
includeCounts: false,
|
||||
});
|
||||
}
|
||||
|
||||
private mutator() {
|
||||
return this.__em.mutator(__bknd);
|
||||
}
|
||||
|
||||
private get db() {
|
||||
// @todo: check why this is neccessary
|
||||
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
|
||||
}
|
||||
|
||||
// @todo: add indices for: version, type
|
||||
async syncConfigTable() {
|
||||
this.logger.context("sync").log("start");
|
||||
const result = await this.__em.schema().sync({ force: true });
|
||||
this.logger.log("done").clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
private rebuildServer() {
|
||||
protected rebuildServer() {
|
||||
this.server = new Hono<ServerEnv>();
|
||||
if (this.options?.basePath) {
|
||||
this.server = this.server.basePath(this.options.basePath);
|
||||
@@ -299,252 +208,81 @@ export class ModuleManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async fetch(): Promise<ConfigTable | undefined> {
|
||||
this.logger.context("fetch").log("fetching");
|
||||
const startTime = performance.now();
|
||||
extractSecrets() {
|
||||
const moduleConfigs = structuredClone(this.configs());
|
||||
const secrets = { ...this.options?.secrets };
|
||||
const extractedKeys: string[] = [];
|
||||
|
||||
// disabling console log, because the table might not exist yet
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
sort: { by: "version", dir: "desc" },
|
||||
},
|
||||
for (const [key, module] of Object.entries(this.modules)) {
|
||||
const config = moduleConfigs[key];
|
||||
const schema = module.getSchema();
|
||||
|
||||
const extracted = [...schema.walk({ data: config })].filter(
|
||||
(n) => n.schema instanceof SecretSchema,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("error fetching").clear();
|
||||
return undefined;
|
||||
}
|
||||
for (const n of extracted) {
|
||||
const path = [key, ...n.instancePath].join(".");
|
||||
|
||||
this.logger
|
||||
.log("took", performance.now() - startTime, "ms", {
|
||||
version: result.version,
|
||||
id: result.id,
|
||||
})
|
||||
.clear();
|
||||
|
||||
return result as unknown as ConfigTable;
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.logger.context("save").log("saving version", this.version());
|
||||
const configs = this.configs();
|
||||
const version = this.version();
|
||||
|
||||
try {
|
||||
const state = await this.fetch();
|
||||
if (!state) throw new BkndError("no config found");
|
||||
this.logger.log("fetched version", state.version);
|
||||
|
||||
if (state.version !== version) {
|
||||
// @todo: mark all others as "backup"
|
||||
this.logger.log("version conflict, storing new version", state.version, version);
|
||||
await this.mutator().insertOne({
|
||||
version: state.version,
|
||||
type: "backup",
|
||||
json: configs,
|
||||
});
|
||||
await this.mutator().insertOne({
|
||||
version: version,
|
||||
type: "config",
|
||||
json: configs,
|
||||
});
|
||||
} else {
|
||||
this.logger.log("version matches", state.version);
|
||||
|
||||
// clean configs because of Diff() function
|
||||
const diffs = $diff.diff(state.json, $diff.clone(configs));
|
||||
this.logger.log("checking diff", [diffs.length]);
|
||||
|
||||
if (diffs.length > 0) {
|
||||
// validate diffs, it'll throw on invalid
|
||||
this.validateDiffs(diffs);
|
||||
|
||||
const date = new Date();
|
||||
// store diff
|
||||
await this.mutator().insertOne({
|
||||
version,
|
||||
type: "diff",
|
||||
json: $diff.clone(diffs),
|
||||
created_at: date,
|
||||
updated_at: date,
|
||||
});
|
||||
|
||||
// store new version
|
||||
await this.mutator().updateWhere(
|
||||
{
|
||||
version,
|
||||
json: configs,
|
||||
updated_at: date,
|
||||
} as any,
|
||||
{
|
||||
type: "config",
|
||||
version,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.logger.log("no diff, not saving");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof BkndError && e.message === "no config found") {
|
||||
this.logger.log("no config, just save fresh");
|
||||
// no config, just save
|
||||
await this.mutator().insertOne({
|
||||
type: "config",
|
||||
version,
|
||||
json: configs,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} else if (e instanceof TransformPersistFailedException) {
|
||||
$console.error("ModuleManager: Cannot save invalid config");
|
||||
this.revertModules();
|
||||
throw e;
|
||||
} else {
|
||||
$console.error("ModuleManager: Aborting");
|
||||
this.revertModules();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// re-apply configs to all modules (important for system entities)
|
||||
this.setConfigs(configs);
|
||||
|
||||
// @todo: cleanup old versions?
|
||||
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
private revertModules() {
|
||||
if (this._stable_configs) {
|
||||
$console.warn("ModuleManager: Reverting modules");
|
||||
this.setConfigs(this._stable_configs as any);
|
||||
} else {
|
||||
$console.error("ModuleManager: No stable configs to revert to");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates received diffs for an additional security control.
|
||||
* Checks:
|
||||
* - check if module is registered
|
||||
* - run modules onBeforeUpdate() for added protection
|
||||
*
|
||||
* **Important**: Throw `Error` so it won't get catched.
|
||||
*
|
||||
* @param diffs
|
||||
* @private
|
||||
*/
|
||||
private validateDiffs(diffs: $diff.DiffEntry[]): void {
|
||||
// check top level paths, and only allow a single module to be modified in a single transaction
|
||||
const modules = [...new Set(diffs.map((d) => d.p[0]))];
|
||||
if (modules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const moduleName of modules) {
|
||||
const name = moduleName as ModuleKey;
|
||||
const module = this.get(name) as Module;
|
||||
if (!module) {
|
||||
const msg = "validateDiffs: module not registered";
|
||||
// biome-ignore format: ...
|
||||
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// pass diffs to the module to allow it to throw
|
||||
if (this._stable_configs?.[name]) {
|
||||
const current = $diff.clone(this._stable_configs?.[name]);
|
||||
const modified = $diff.apply({ [name]: current }, diffs)[name];
|
||||
module.onBeforeUpdate(current, modified);
|
||||
if (typeof n.data === "string") {
|
||||
extractedKeys.push(path);
|
||||
secrets[path] = n.data;
|
||||
setPath(moduleConfigs, path, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setConfigs(configs: ModuleConfigs): void {
|
||||
return {
|
||||
configs: moduleConfigs,
|
||||
secrets: pick(secrets, extractedKeys),
|
||||
extractedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
|
||||
this.logger.log("setting configs");
|
||||
objectEach(configs, (config, key) => {
|
||||
for await (const [key, config] of Object.entries(configs)) {
|
||||
if (!(key in this.modules)) continue;
|
||||
|
||||
try {
|
||||
// setting "noEmit" to true, to not force listeners to update
|
||||
this.modules[key].schema().set(config as any, true);
|
||||
const result = await this.modules[key].schema().set(config as any, true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async build(opts?: { fetch?: boolean }) {
|
||||
this.logger.context("build").log("version", this.version());
|
||||
await this.ctx().connection.init();
|
||||
|
||||
// if no config provided, try fetch from db
|
||||
if (this.version() === 0 || opts?.fetch === true) {
|
||||
if (opts?.fetch) {
|
||||
this.logger.log("force fetch");
|
||||
}
|
||||
|
||||
const result = await this.fetch();
|
||||
|
||||
// if no version, and nothing found, go with initial
|
||||
if (!result) {
|
||||
this.logger.log("nothing in database, go initial");
|
||||
await this.setupInitial();
|
||||
} else {
|
||||
this.logger.log("db has", result.version);
|
||||
// set version and config from fetched
|
||||
this._version = result.version;
|
||||
|
||||
if (this.options?.trustFetched === true) {
|
||||
this.logger.log("trusting fetched config (mark)");
|
||||
mark(result.json);
|
||||
}
|
||||
|
||||
// if version doesn't match, migrate before building
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
this.logger.log("now migrating");
|
||||
|
||||
await this.syncConfigTable();
|
||||
|
||||
const version_before = this.version();
|
||||
const [_version, _configs] = await migrate(version_before, result.json, {
|
||||
db: this.db,
|
||||
});
|
||||
|
||||
this._version = _version;
|
||||
this.ctx().flags.sync_required = true;
|
||||
|
||||
this.logger.log("migrated to", _version);
|
||||
$console.log("Migrated config from", version_before, "to", this.version());
|
||||
|
||||
this.createModules(_configs);
|
||||
await this.buildModules();
|
||||
} else {
|
||||
this.logger.log("version is current", this.version());
|
||||
this.createModules(result.json);
|
||||
async build(opts?: any) {
|
||||
this.createModules(this.options?.initial ?? {});
|
||||
await this.buildModules();
|
||||
|
||||
// if secrets were provided, extract, merge and build again
|
||||
const provided_secrets = this.options?.secrets ?? {};
|
||||
if (Object.keys(provided_secrets).length > 0) {
|
||||
const { configs, extractedKeys } = this.extractSecrets();
|
||||
|
||||
for (const key of extractedKeys) {
|
||||
if (key in provided_secrets) {
|
||||
setPath(configs, key, provided_secrets[key]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
throw new Error(
|
||||
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
|
||||
);
|
||||
}
|
||||
this.logger.log("current version is up to date", this.version());
|
||||
|
||||
await this.setConfigs(configs);
|
||||
await this.buildModules();
|
||||
}
|
||||
|
||||
this.logger.log("done");
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
||||
protected async buildModules(options?: {
|
||||
graceful?: boolean;
|
||||
ignoreFlags?: boolean;
|
||||
drop?: boolean;
|
||||
}) {
|
||||
const state = {
|
||||
built: false,
|
||||
modules: [] as ModuleKey[],
|
||||
@@ -580,12 +318,8 @@ export class ModuleManager {
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await ctx.em.schema().sync({ force: true, drop: options?.drop });
|
||||
state.synced = true;
|
||||
|
||||
// save
|
||||
await this.save();
|
||||
state.saved = true;
|
||||
}
|
||||
|
||||
if (ctx.flags.ctx_reload_required) {
|
||||
@@ -601,92 +335,12 @@ export class ModuleManager {
|
||||
ctx.flags = Module.ctx_flags;
|
||||
|
||||
// storing last stable config version
|
||||
this._stable_configs = $diff.clone(this.configs());
|
||||
//this._stable_configs = $diff.clone(this.configs());
|
||||
|
||||
this.logger.clear();
|
||||
return state;
|
||||
}
|
||||
|
||||
protected async setupInitial() {
|
||||
this.logger.context("initial").log("start");
|
||||
this._version = CURRENT_VERSION;
|
||||
await this.syncConfigTable();
|
||||
const state = await this.buildModules();
|
||||
if (!state.saved) {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
...this.ctx(),
|
||||
// disable events for initial setup
|
||||
em: this.ctx().em.fork(),
|
||||
};
|
||||
|
||||
// perform a sync
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await this.options?.seed?.(ctx);
|
||||
|
||||
// run first boot event
|
||||
await this.options?.onFirstBoot?.();
|
||||
this.logger.clear();
|
||||
}
|
||||
|
||||
mutateConfigSafe<Module extends keyof Modules>(
|
||||
name: Module,
|
||||
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
|
||||
const module = this.modules[name];
|
||||
|
||||
return new Proxy(module.schema(), {
|
||||
get: (target, prop: string) => {
|
||||
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
|
||||
throw new Error(`Method ${prop} is not allowed`);
|
||||
}
|
||||
|
||||
return async (...args) => {
|
||||
$console.log("[Safe Mutate]", name);
|
||||
try {
|
||||
// overwrite listener to run build inside this try/catch
|
||||
module.setListener(async () => {
|
||||
await this.emgr.emit(
|
||||
new ModuleManagerConfigUpdateEvent({
|
||||
ctx: this.ctx(),
|
||||
module: name,
|
||||
config: module.config as any,
|
||||
}),
|
||||
);
|
||||
await this.buildModules();
|
||||
});
|
||||
|
||||
const result = await target[prop](...args);
|
||||
|
||||
// revert to original listener
|
||||
module.setListener(async (c) => {
|
||||
await this.onModuleConfigUpdated(name, c);
|
||||
});
|
||||
|
||||
// if there was an onUpdated listener, call it after success
|
||||
// e.g. App uses it to register module routes
|
||||
if (this.options?.onUpdated) {
|
||||
await this.options.onUpdated(name, module.config as any);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
$console.error(`[Safe Mutate] failed "${name}":`, e);
|
||||
|
||||
// revert to previous config & rebuild using original listener
|
||||
this.revertModules();
|
||||
await this.onModuleConfigUpdated(name, module.config as any);
|
||||
$console.warn(`[Safe Mutate] reverted "${name}":`);
|
||||
|
||||
// make sure to throw the error
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get<K extends keyof Modules>(key: K): Modules[K] {
|
||||
if (!(key in this.modules)) {
|
||||
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
||||
@@ -695,7 +349,7 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
version() {
|
||||
return this._version;
|
||||
return 0;
|
||||
}
|
||||
|
||||
built() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user