Release 0.12 (#143)

* changed tb imports

* cleanup: replace console.log/warn with $console, remove commented-out code

Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.

* ts: enable incremental

* fix imports in test files

reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.

* added media permissions (#142)

* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add bcrypt and refactored auth resolve (#147)

* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test

* add validation logs and improve data validation handling (#157)

Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.

* modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)

* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types

* update admin basepath handling and window context integration (#155)

Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.

* trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160)

* refactor error handling in authenticator and password strategy (#161)

made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.

* add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)

Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.

* update dependencies in package.json (#156)

moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.

* update imports to adjust nodeTestRunner path and remove unused export (#163)

updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.

* fix sync events not awaited (#164)

* refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)

Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.

* replace LiquidJs rendering with simplified renderer (#167)

* replace LiquidJs rendering with simplified renderer

Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.

* remove liquid js from package json

* feat/cli-generate-types (#166)

* init types generation

* update type generation for entities and fields

Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files.

* update type generation code and CLI option description

removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description.

* fix json schema field type generation

* reworked system entities to prevent recursive types

* reworked system entities to prevent recursive types

* remove unused object function

* types: use number instead of Generated

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
dswbx
2025-05-01 10:12:18 +02:00
committed by GitHub
parent d6f94a2ce1
commit 372f94d22a
186 changed files with 2617 additions and 1997 deletions

View File

@@ -1,8 +1,8 @@
/// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
import { MediaApi } from "../../src/media/api/MediaApi";
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono()
@@ -39,10 +39,28 @@ describe("MediaApi", () => {
// @ts-ignore tests
const api = new MediaApi({
token: "token",
token_transport: "header",
});
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
});
it("should return empty headers if not using `header` transport", () => {
expect(
new MediaApi({
token_transport: "cookie",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
expect(
new MediaApi({
token_transport: "none",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
});
it("should get file: native", async () => {
const name = "image.png";
const path = `${assetsTmpPath}/${name}`;
@@ -103,8 +121,12 @@ describe("MediaApi", () => {
});
it("should upload file in various ways", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const api = new MediaApi(
{
upload_fetcher: mockedBackend.request,
},
mockedBackend.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) {

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils";
import { type TObject, type TString, Type } from "@sinclair/typebox";
import { Registry } from "core";
type Constructor<T> = new (...args: any[]) => T;

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core";
import { Type } from "../../../src/core/utils";
import { Type } from "@sinclair/typebox";
describe("SchemaObject", async () => {
test("basic", async () => {

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "../../../src/data";
import { Entity, NumberField, TextField } from "data";
import * as p from "data/prototype";
describe("[data] Entity", async () => {
const entity = new Entity("test", [
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field);
});
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
const json = entity.toJSON();
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
test.only("types", async () => {
console.log(entity.toTypes());
});
});

View File

@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]);

View File

@@ -266,5 +266,12 @@ describe("[data] Repository (Events)", async () => {
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
// check find one on findMany with limit 1
await repo.findMany({ where: { id: 1 }, limit: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils";
import { Type } from "@sinclair/typebox";
import { Entity, EntityIndex, Field } from "../../../../src/data";
class TestField extends Field {

View File

@@ -1,5 +1,23 @@
import { describe, expect, test } from "bun:test";
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
import { Type } from "@sinclair/typebox";
export class StringifyTask<Output extends string> extends Task<
typeof StringifyTask.schema,
Output
> {
type = "stringify";
static override schema = Type.Optional(
Type.Object({
input: Type.Optional(Type.String()),
}),
);
async execute() {
return JSON.stringify(this.params.input) as Output;
}
}
describe("SubFlowTask", async () => {
test("Simple Subflow", async () => {
@@ -22,8 +40,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution();
await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow");
});
@@ -40,8 +56,8 @@ describe("SubFlowTask", async () => {
loop: true,
input: [1, 2, 3],
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`,
const task3 = new StringifyTask("stringify", {
input: "{{ sub.output }}",
});
const flow = new Flow("test", [task, task2, task3], []);
@@ -51,41 +67,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution();
await execution.start();
console.log("errors", execution.getErrors());
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
});
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}",
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}",
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`,
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start([4, 5, 6]);
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils";
import { Type } from "@sinclair/typebox";
import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
@@ -51,62 +51,4 @@ describe("Task", async () => {
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })),
}),
{
test: "{{ some | json }}",
},
{
some: {
key: "path",
value: "1/1",
},
},
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String())),
}),
{
test: '{{ "1,2,3" | split: "," | json }}',
},
);
expect(result.test).toEqual(["1", "2", "3"]);
});
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean()),
}),
{
test: "{{ true }}",
},
);
expect(result.test).toEqual(true);
});
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat),
}),
{
test: "{{ 3.14 }}",
},
);
expect(result.test).toEqual(3.14);
});
});

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils";
import { parse } from "../../src/core/utils";
import { type Static, type StaticDecode, Type } from "@sinclair/typebox";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";

View File

@@ -1,7 +1,8 @@
// eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
import { _jsonp, withDisabledConsole } from "../../src/core/utils";
import { type Static, Type } from "@sinclair/typebox";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);

View File

@@ -69,7 +69,7 @@ describe("AppAuth", () => {
},
body: JSON.stringify({
email: "some@body.com",
password: "123456",
password: "12345678",
}),
});
enableConsoleLog();
@@ -81,6 +81,65 @@ describe("AppAuth", () => {
}
});
test("creates user on register (bcrypt)", async () => {
const auth = new AppAuth(
{
enabled: true,
strategies: {
password: {
type: "password",
config: {
hashing: "bcrypt",
},
},
},
// @ts-ignore
jwt: {
secret: "123456",
},
},
ctx,
);
await auth.build();
await ctx.em.schema().sync({ force: true });
// expect no users, but the query to pass
const res = await ctx.em.repository("users").findMany();
expect(res.data.length).toBe(0);
const app = new AuthController(auth).getController();
{
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "some@body.com",
password: "12345678",
}),
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1);
expect(users[0]?.email).toBe("some@body.com");
}
{
// check user in database
const rawUser = await ctx.connection.kysely
.selectFrom("users")
.selectAll()
.executeTakeFirstOrThrow();
expect(rawUser.strategy_value).toStartWith("$");
}
});
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../../src/core/utils";
import { stripMark } from "../../src/core/utils";
import { type TSchema, Type } from "@sinclair/typebox";
import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module";
@@ -9,10 +10,10 @@ function createModule<Schema extends TSchema>(schema: Schema) {
getSchema() {
return schema;
}
toJSON() {
override toJSON() {
return this.config;
}
useForceParse() {
override useForceParse() {
return true;
}
}

View File

@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils";
import { Connection, entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
import { Type } from "@sinclair/typebox";
import { Connection, entity, text } from "data";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";

View File

@@ -14,7 +14,7 @@
"url": "https://github.com/bknd-io/bknd/issues"
},
"scripts": {
"dev": "vite",
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
@@ -48,16 +48,15 @@
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
@@ -65,14 +64,12 @@
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"wrangler": "^4.4.1"
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
@@ -108,6 +105,7 @@
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",

View File

@@ -8,6 +8,11 @@ import { omitKeys } from "core/utils";
export type TApiUser = SafeUser;
export type ApiFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
declare global {
interface Window {
__BKND__: {
@@ -21,7 +26,7 @@ export type ApiOptions = {
headers?: Headers;
key?: string;
localStorage?: boolean;
fetcher?: typeof fetch;
fetcher?: ApiFetcher;
verbose?: boolean;
verified?: boolean;
} & (
@@ -117,8 +122,6 @@ export class Api {
this.updateToken(token);
}
}
//console.warn("Couldn't extract token");
}
updateToken(token?: string, rebuild?: boolean) {

View File

@@ -15,7 +15,7 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there
// biome-ignore format: must be here
import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";

View File

@@ -7,6 +7,7 @@ import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core";
export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
@@ -37,7 +38,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
const url = new URL(request.url);
if (config.manifest && config.static === "assets") {
console.warn("manifest is not useful with static 'assets'");
$console.warn("manifest is not useful with static 'assets'");
} else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'");
}

View File

@@ -5,6 +5,7 @@ import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono";
import { $console } from "core";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
@@ -27,12 +28,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
$console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}

View File

@@ -3,6 +3,7 @@ import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
@@ -13,7 +14,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
}
const start = performance.now();

View File

@@ -20,7 +20,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
config.onBuilt?.(app);
await config.onBuilt?.(app);
},
},
ctx.env,

View File

@@ -3,7 +3,7 @@ import { test } from "node:test";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node";
import { nodeTestRunner } from "adapter/node/test";
import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480

View File

@@ -1,8 +1,10 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils";
import { StringEnum } from "bknd/utils";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export function makeSchema(bindings: string[] = []) {
return Type.Object(
@@ -122,12 +124,10 @@ export class StorageR2Adapter extends StorageAdapter {
}
}
//console.log("response headers:before", headersToObject(responseHeaders));
this.writeHttpMetadata(responseHeaders, object);
responseHeaders.set("etag", object.httpEtag);
responseHeaders.set("Content-Length", String(object.size));
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
//console.log("response headers:after", headersToObject(responseHeaders));
return new Response(object.body, {
status: object.range ? 206 : 200,

View File

@@ -3,7 +3,6 @@ import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageL
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
let registered = false;
export function registerLocalMediaAdapter() {

View File

@@ -1,7 +1,7 @@
import { describe, before, after } from "node:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node";
import { nodeTestRunner } from "adapter/node/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog());

View File

@@ -4,6 +4,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
import { $console } from "core";
type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
@@ -62,7 +63,7 @@ export function serve<Env = NodeEnv>(
fetch: createHandler(config, args, opts),
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
$console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
},
);

View File

@@ -1,5 +1,6 @@
import { describe } from "node:test";
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
import { nodeTestRunner } from "adapter/node/test";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";

View File

@@ -1,7 +1,9 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, isFile, parse } from "bknd/utils";
import { type Static, isFile, parse } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export const localAdapterConfig = Type.Object(
{

View File

@@ -1,29 +1,23 @@
import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy,
} from "auth";
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { $console, type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils";
import { $console, type DB } from "core";
import { secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import { pick } from "lodash-es";
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB {
users: { id: PrimaryFieldType } & UserFieldSchema;
users: Users;
}
}
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> {
@@ -31,12 +25,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
cache: Record<string, any> = {};
_controller!: AuthController;
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
if (!from.enabled && to.enabled) {
if (to.jwt.secret === defaultSecret) {
console.warn("No JWT secret provided, generating a random one");
$console.warn("No JWT secret provided, generating a random one");
to.jwt.secret = secureRandomString(64);
}
}
@@ -80,7 +74,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}
});
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
jwt: this.config.jwt,
cookie: this.config.cookie,
});
@@ -90,7 +84,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this._controller = new AuthController(this);
this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
this.ctx.guard.registerPermissions(AuthPermissions);
}
isStrategyEnabled(strategy: Strategy | string) {
@@ -122,120 +116,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.ctx.em as any;
}
private async resolveUser(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
): Promise<any> {
if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403);
}
const fields = this.getUsersEntity()
.getFillableFields("create")
.map((f) => f.name);
const filteredProfile = Object.fromEntries(
Object.entries(profile).filter(([key]) => fields.includes(key)),
);
switch (action) {
case "login":
return this.login(strategy, identifier, filteredProfile);
case "register":
return this.register(strategy, identifier, filteredProfile);
}
}
private filterUserData(user: any) {
return pick(user, this.config.jwt.fields);
}
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true);
const result = await this.em
.repo(users as unknown as "users")
.findOne({ email: profile.email! });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
throw new Exception("User not found", 404);
}
// compare strategy and identifier
if (result.data.strategy !== strategy.getName()) {
//console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
if (result.data.strategy_value !== identifier) {
throw new Exception("Invalid credentials");
}
return this.filterUserData(result.data);
}
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have an email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
if (data) {
throw new Exception("User already exists");
}
const payload: any = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier,
};
const mutator = this.em.mutator(users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new Error("Could not create user");
}
return this.filterUserData(createResult.data);
}
private toggleStrategyValueVisibility(visible: boolean) {
const toggle = (name: string, visible: boolean) => {
const field = this.getUsersEntity().field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save?
}
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) {
@@ -288,7 +168,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Error("Cannot create user, auth not enabled");
}
const strategy = "password";
const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users");
@@ -315,8 +195,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
type: strategy.getType(),
config: strategy.toJSON(secrets),
...strategy.toJSON(secrets),
})),
};
}

View File

@@ -0,0 +1,83 @@
import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core";
import { pick } from "lodash-es";
import {
InvalidConditionsException,
UnableToCreateUserException,
UserNotFoundException,
} from "auth/errors";
export class AppUserPool implements UserPool {
constructor(private appAuth: AppAuth) {}
get em() {
return this.appAuth.em;
}
get users() {
return this.appAuth.getUsersEntity();
}
async findBy(strategy: string, prop: keyof SafeUser, value: any) {
$console.debug("[AppUserPool:findBy]", { strategy, prop, value });
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
$console.debug("[AppUserPool]: User not found");
throw new UserNotFoundException();
}
return result.data;
}
async create(strategy: string, payload: CreateUser & Partial<Omit<User, "id">>) {
$console.debug("[AppUserPool:create]", { strategy, payload });
if (!("strategy_value" in payload)) {
throw new InvalidConditionsException("Profile must have a strategy_value value");
}
const fields = this.users.getSelect(undefined, "create");
const safeProfile = pick(payload, fields) as any;
const createPayload: Omit<User, "id"> = {
...safeProfile,
strategy,
};
const mutator = this.em.mutator(this.users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(createPayload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new UnableToCreateUserException();
}
$console.debug("[AppUserPool]: User created", createResult.data);
return createResult.data;
}
private toggleStrategyValueVisibility(visible: boolean) {
const toggle = (name: string, visible: boolean) => {
const field = this.users.field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save?
}
}

View File

@@ -1,9 +1,11 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export type AuthActionResponse = {
success: boolean;

View File

@@ -1,6 +1,8 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
import { type Static, StringRecord, objectTransform } from "core/utils";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export const Strategies = {
password: {

View File

@@ -1,13 +1,12 @@
import { type DB, Exception, type PrimaryFieldType } from "core";
import { $console, type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import {
type Static,
StringEnum,
type TObject,
Type,
parse,
runtimeSupports,
transformObject,
truncate,
} from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
@@ -15,6 +14,9 @@ import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
import * as tbbox from "@sinclair/typebox";
import { InvalidConditionsException } from "auth/errors";
const { Type } = tbbox;
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
@@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference
// @todo: add tests (e.g. invalid strategy_value)
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
getType: () => string;
@@ -37,28 +40,22 @@ export interface Strategy {
getActions?: () => StrategyActions;
}
export type User = {
id: PrimaryFieldType;
email: string;
password: string;
role?: string | null;
};
export type User = DB["users"];
export type ProfileExchange = {
email?: string;
username?: string;
sub?: string;
password?: string;
strategy?: string;
strategy_value?: string;
[key: string]: any;
};
export type SafeUser = Omit<User, "password">;
export type SafeUser = Omit<User, "strategy_value">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (user: CreateUser) => Promise<User | undefined>;
export interface UserPool {
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
create: (strategy: string, user: CreateUser) => Promise<User>;
}
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
@@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({
type AuthConfig = Static<typeof authenticatorConfig>;
export type AuthAction = "login" | "register";
export type AuthResolveOptions = {
identifier?: "email" | string;
redirect?: string;
forceJsonResponse?: boolean;
};
export type AuthUserResolver = (
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
) => Promise<SafeUser | undefined>;
opts?: AuthResolveOptions,
) => Promise<ProfileExchange | undefined>;
type AuthClaims = SafeUser & {
iat: number;
iss?: string;
@@ -113,33 +115,117 @@ type AuthClaims = SafeUser & {
};
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
this.strategies = strategies as Strategies;
constructor(
private readonly strategies: Strategies,
private readonly userPool: UserPool,
config?: AuthConfig,
) {
this.config = parse(authenticatorConfig, config ?? {});
}
async resolve(
action: AuthAction,
async resolveLogin(
c: Context,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
): Promise<AuthResponse> {
//console.log("resolve", { action, strategy: strategy.getName(), profile });
const user = await this.userResolver(action, strategy, identifier, profile);
if (user) {
return {
user,
token: await this.jwt(user),
};
profile: Partial<SafeUser>,
verify: (user: User) => Promise<void>,
opts?: AuthResolveOptions,
) {
try {
// @todo: centralize identifier and checks
// @todo: check identifier value (if allowed)
const identifier = opts?.identifier || "email";
if (typeof identifier !== "string" || identifier.length === 0) {
throw new InvalidConditionsException("Identifier must be a string");
}
if (!(identifier in profile)) {
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
}
throw new Error("User could not be resolved");
const user = await this.userPool.findBy(
strategy.getName(),
identifier as any,
profile[identifier],
);
if (!user.strategy_value) {
throw new InvalidConditionsException("User must have a strategy value");
} else if (user.strategy !== strategy.getName()) {
throw new InvalidConditionsException("User signed up with a different strategy");
}
await verify(user);
const data = await this.safeAuthResponse(user);
return this.respondWithUser(c, data, opts);
} catch (e) {
return this.respondWithError(c, e as Error, opts);
}
}
async resolveRegister(
c: Context,
strategy: Strategy,
profile: CreateUser,
verify: (user: User) => Promise<void>,
opts?: AuthResolveOptions,
) {
try {
const identifier = opts?.identifier || "email";
if (typeof identifier !== "string" || identifier.length === 0) {
throw new InvalidConditionsException("Identifier must be a string");
}
if (!(identifier in profile)) {
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
}
if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value");
}
const user = await this.userPool.create(strategy.getName(), {
...profile,
strategy_value: profile.strategy_value,
});
await verify(user);
const data = await this.safeAuthResponse(user);
return this.respondWithUser(c, data, opts);
} catch (e) {
return this.respondWithError(c, e as Error, opts);
}
}
private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) {
const successUrl = this.getSafeUrl(
c,
opts?.redirect ?? this.config.cookie.pathSuccess ?? "/",
);
if ("token" in data) {
await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs
return c.redirect(successUrl);
}
throw new Exception("Invalid response");
}
async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) {
$console.error("respondWithError", error);
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
// let the server handle it
throw error;
}
await addFlashMessage(c, String(error), "error");
const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/");
return c.redirect(referer);
}
getStrategies(): Strategies {
@@ -158,7 +244,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
// @todo: add jwt tests
async jwt(_user: Omit<User, "password">): Promise<string> {
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const payload: JWTPayload = {
@@ -184,6 +270,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
}
async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pick(_user, this.config.jwt.fields) as SafeUser;
return {
user,
token: await this.jwt(user),
};
}
async verify(jwt: string): Promise<AuthClaims | undefined> {
try {
const payload = await verify(
@@ -225,7 +319,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return token;
} catch (e: any) {
if (e instanceof Error) {
console.error("[Error:getAuthCookie]", e.message);
$console.error("[getAuthCookie]", e.message);
}
return undefined;
@@ -242,11 +336,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
$console.debug("setting auth cookie", truncate(token));
const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
private async deleteAuthCookie(c: Context) {
$console.debug("deleting auth cookie");
await deleteCookie(c, "auth", this.cookieOptions);
}
@@ -262,7 +358,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
// @todo: move this to a server helper
isJsonRequest(c: Context): boolean {
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
return c.req.header("Content-Type") === "application/json";
}
@@ -286,37 +381,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return p;
}
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) {
await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl);
}
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured";
if (data instanceof Exception) {
message = data.message;
}
await addFlashMessage(c, message, "error");
//console.log("auth failed, redirecting to", referer);
return c.redirect(referer);
}
// @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
let token: string | undefined;
@@ -341,13 +405,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
};
}
}
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
) {
return {
schema,
preprocess,
} as StrategyAction<S>;
}

View File

@@ -1,152 +1,135 @@
import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { type Context, Hono } from "hono";
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
import { $console, tbValidator as tb } from "core";
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import * as tbbox from "@sinclair/typebox";
import { Strategy } from "./Strategy";
type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
const { Type } = tbbox;
const schema = Type.Object({
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }),
const schema = StrictObject({
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }),
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
});
export type PasswordStrategyOptions = Static<typeof schema>;
/*export type PasswordStrategyOptions2 = {
hashing?: "plain" | "bcrypt" | "sha256";
};*/
export class PasswordStrategy implements Strategy {
private options: PasswordStrategyOptions;
export class PasswordStrategy extends Strategy<typeof schema> {
constructor(config: Partial<PasswordStrategyOptions> = {}) {
super(config as any, "password", "password", "form");
constructor(options: Partial<PasswordStrategyOptions> = {}) {
this.options = parse(schema, options);
}
async hash(password: string) {
switch (this.options.hashing) {
case "sha256":
return hash.sha256(password);
default:
return password;
}
}
async login(input: LoginSchema) {
if (!("email" in input) || !("password" in input)) {
throw new Error("Invalid input: Email and password must be provided");
}
const hashedPassword = await this.hash(input.password);
return { ...input, password: hashedPassword };
}
async register(input: RegisterSchema) {
if (!input.email || !input.password) {
throw new Error("Invalid input: Email and password must be provided");
}
return {
...input,
password: await this.hash(input.password),
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
return hono
.post(
"/login",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String()),
}),
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
try {
const payload = await this.login(body);
const data = await authenticator.resolve(
"login",
this,
payload.password,
payload,
);
return await authenticator.respond(c, data, redirect);
} catch (e) {
return await authenticator.respond(c, e);
}
},
)
.post(
"/register",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String()),
}),
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
const payload = await this.register(body);
const data = await authenticator.resolve(
"register",
this,
payload.password,
payload,
);
return await authenticator.respond(c, data, redirect);
},
);
}
getActions(): StrategyActions {
return {
create: createStrategyAction(
Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
minLength: 8, // @todo: this should be configurable
}),
}),
async ({ password, ...input }) => {
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password),
};
},
),
};
});
}
getSchema() {
return schema;
}
getType() {
return "password";
private getPayloadSchema() {
return Type.Object({
email: Type.String({
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
minLength: 8, // @todo: this should be configurable
}),
});
}
getMode() {
return "form" as const;
async hash(password: string) {
switch (this.config.hashing) {
case "sha256":
return hash.sha256(password);
case "bcrypt": {
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
return bcryptHash(password, salt);
}
default:
return password;
}
}
getName() {
return "password" as const;
async compare(actual: string, compare: string): Promise<boolean> {
switch (this.config.hashing) {
case "sha256": {
const compareHashed = await this.hash(compare);
return actual === compareHashed;
}
case "bcrypt":
return await bcryptCompare(compare, actual);
}
toJSON(secrets?: boolean) {
return secrets ? this.options : undefined;
return false;
}
verify(password: string) {
return async (user: User) => {
const compare = await this.compare(user?.strategy_value!, password);
if (compare !== true) {
throw new InvalidCredentialsException();
}
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
const redirectQuerySchema = Type.Object({
redirect: Type.Optional(Type.String()),
});
const payloadSchema = this.getPayloadSchema();
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
try {
const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => {
$console.error("Invalid login payload", [...errors]);
throw new InvalidCredentialsException();
},
});
const { redirect } = c.req.valid("query");
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
},
},
);
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
return hono;
}
}

View File

@@ -0,0 +1,63 @@
import type {
Authenticator,
StrategyAction,
StrategyActionName,
StrategyActions,
} from "../Authenticator";
import type { Hono } from "hono";
import type { Static, TSchema } from "@sinclair/typebox";
import { parse, type TObject } from "core/utils";
export type StrategyMode = "form" | "external";
export abstract class Strategy<Schema extends TSchema = TSchema> {
protected actions: StrategyActions = {};
constructor(
protected config: Static<Schema>,
public type: string,
public name: string,
public mode: StrategyMode,
) {
// don't worry about typing, it'll throw if invalid
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
}
protected registerAction<S extends TObject = TObject>(
name: StrategyActionName,
schema: S,
preprocess: StrategyAction<S>["preprocess"],
): void {
this.actions[name] = {
schema,
preprocess,
} as const;
}
protected abstract getSchema(): Schema;
abstract getController(auth: Authenticator): Hono;
getType(): string {
return this.type;
}
getMode() {
return this.mode;
}
getName(): string {
return this.name;
}
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
return {
type: this.getType(),
config: secrets ? this.config : undefined,
};
}
getActions(): StrategyActions {
return this.actions;
}
}

View File

@@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
export * as issuers from "./oauth/issuers";
export {
PasswordStrategy,
type PasswordStrategyOptions,
PasswordStrategy,
OAuthStrategy,
OAuthCallbackException,
CustomOAuthStrategy,

View File

@@ -1,28 +1,24 @@
import { type Static, StringEnum, Type } from "core/utils";
import { type Static, StrictObject, StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy";
const { Type } = tbbox;
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = Type.Object(
const oauthSchemaCustom = StrictObject(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(),
client: Type.Object(
{
client: StrictObject({
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
},
{
additionalProperties: false,
},
),
as: Type.Object(
{
}),
as: StrictObject({
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
@@ -30,14 +26,10 @@ const oauthSchemaCustom = Type.Object(
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString),
},
{
additionalProperties: false,
},
),
}),
// @todo: profile mapping
},
{ title: "Custom OAuth", additionalProperties: false },
{ title: "Custom OAuth" },
);
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
@@ -62,6 +54,11 @@ export type IssuerConfig<UserInfo = any> = {
};
export class CustomOAuthStrategy extends OAuthStrategy {
constructor(config: OAuthConfigCustom) {
super(config as any);
this.type = "custom_oauth";
}
override getIssuerConfig(): IssuerConfig {
return { ...this.config, profile: async (info) => info } as any;
}
@@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy {
override getSchema() {
return oauthSchemaCustom;
}
override getType() {
return "custom_oauth";
}
}

View File

@@ -1,10 +1,13 @@
import type { AuthAction, Authenticator, Strategy } from "auth";
import type { AuthAction, Authenticator } from "auth";
import { Exception, isDebug } from "core";
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
import * as tbbox from "@sinclair/typebox";
import { Strategy } from "auth/authenticate/strategies/Strategy";
const { Type } = tbbox;
type ConfiguredIssuers = keyof typeof issuers;
type SupportedTypes = "oauth2" | "oidc";
@@ -13,17 +16,12 @@ type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & O
const schemaProvided = Type.Object(
{
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
client: Type.Object(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
client: StrictObject({
client_id: Type.String(),
client_secret: Type.String(),
},
{
additionalProperties: false,
},
),
}),
},
{ title: "OAuth" },
);
@@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception {
}
}
export class OAuthStrategy implements Strategy {
constructor(private _config: OAuthConfig) {}
export class OAuthStrategy extends Strategy<typeof schemaProvided> {
constructor(config: ProvidedOAuthConfig) {
super(config, "oauth", config.name, "external");
}
get config() {
return this._config;
getSchema() {
return schemaProvided;
}
getIssuerConfig(): IssuerConfig {
@@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy {
type: info.type,
client: {
...info.client,
...this._config.client,
...this.config.client,
},
};
}
@@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy {
) {
const config = await this.getConfig();
const { client, as, type } = config;
//console.log("config", config);
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
@@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy {
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
//console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
/*console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);*/
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
@@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy {
options.redirect_uri,
options.state,
);
//console.log("callback.response", response);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
@@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy {
expectedNonce,
);
if (oauth.isOAuth2Error(result)) {
console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
}
//console.log("callback.result", result);
const claims = oauth.getValidatedIdTokenClaims(result);
//console.log("callback.IDTokenClaims", claims);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
//console.log("callback.resultUser", resultUser);
return await config.profile(resultUser, config, claims); // @todo: check claims
}
@@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy {
) {
const config = await this.getConfig();
const { client, type, as, profile } = config;
console.log("config", { client, as, type });
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
@@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy {
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
@@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy {
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
@@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy {
try {
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
console.log("error", result);
throw new Error(); // Handle OAuth 2.0 response body error
}
} catch (e) {
result = (await copy.json()) as any;
console.log("failed", result);
}
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json();
console.log("res2", res2, user);
console.log("result", result);
return await config.profile(user, config, result);
}
@@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy {
): Promise<UserProfile> {
const type = this.getIssuerConfig().type;
console.log("type", type);
switch (type) {
case "oidc":
return await this.oidc(callbackParams, options);
@@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy {
};
const setState = async (c: Context, config: TState): Promise<void> => {
console.log("--- setting state", config);
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true,
httpOnly: true,
@@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy {
const params = new URLSearchParams(url.search);
const state = await getState(c);
console.log("state", state);
// @todo: add config option to determine if state.action is allowed
const redirect_uri =
@@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy {
state: state.state,
});
try {
const data = await auth.resolve(state.action, this, profile.sub, profile);
console.log("******** RESOLVED ********", data);
const safeProfile = {
email: profile.email,
strategy_value: profile.sub,
} as const;
if (state.mode === "cookie") {
return await auth.respond(c, data, state.redirect);
const verify = async (user) => {
if (user.strategy_value !== profile.sub) {
throw new Exception("Invalid credentials");
}
};
const opts = {
redirect: state.redirect,
forceJsonResponse: state.mode !== "cookie",
} as const;
return c.json(data);
} catch (e) {
if (state.mode === "cookie") {
return await auth.respond(c, e, state.redirect);
}
throw e;
switch (state.action) {
case "login":
return auth.resolveLogin(c, this, safeProfile, verify, opts);
case "register":
return auth.resolveRegister(c, this, safeProfile, verify, opts);
default:
throw new Error("Invalid action");
}
});
@@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy {
redirect_uri,
state,
});
//console.log("_state", state);
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
console.log("--redirecting to", response.url);
return c.redirect(response.url);
});
@@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy {
return hono;
}
getType() {
return "oauth";
}
getMode() {
return "external" as const;
}
getName() {
return this.config.name;
}
getSchema() {
return schemaProvided;
}
toJSON(secrets?: boolean) {
override toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
type: this.getIssuerConfig().type,
...super.toJSON(secrets),
config: {
...config,
type: this.getIssuerConfig().type,
},
};
}
}

View File

@@ -34,8 +34,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
config: Omit<IssuerConfig, "profile">,
tokenResponse: any,
) => {
console.log("github info", info, config, tokenResponse);
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
@@ -45,7 +43,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
},
});
const data = (await res.json()) as GithubUserEmailResponse;
console.log("data", data);
const email = data.find((e: any) => e.primary)?.email;
if (!email) {
throw new Error("No primary email found");

View File

@@ -1,4 +1,4 @@
import { Exception, Permission } from "core";
import { $console, Exception, Permission } from "core";
import { objectTransform } from "core/utils";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
@@ -14,8 +14,6 @@ export type GuardConfig = {
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
const debug = false;
export class Guard {
permissions: Permission[];
roles?: Role[];
@@ -83,8 +81,12 @@ export class Guard {
return this;
}
registerPermissions(permissions: Permission[]) {
for (const permission of permissions) {
registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
this.registerPermission(permission);
}
@@ -95,15 +97,13 @@ export class Guard {
if (user && typeof user.role === "string") {
const role = this.roles?.find((role) => role.name === user?.role);
if (role) {
debug && console.log("guard: role found", [user.role]);
$console.debug(`guard: role "${user.role}" found`);
return role;
}
}
debug &&
console.log("guard: role not found", {
user: user,
role: user?.role,
$console.debug("guard: role not found", {
user,
});
return this.getDefaultRole();
}
@@ -120,11 +120,14 @@ export class Guard {
hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
//console.log("guard not enabled, allowing");
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", {
name,
user: { id: user?.id, role: user?.role },
});
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
@@ -133,10 +136,10 @@ export class Guard {
const role = this.getUserRole(user);
if (!role) {
debug && console.log("guard: role not found, denying");
$console.debug("guard: user has no role, denying");
return false;
} else if (role.implicit_allow === true) {
debug && console.log("guard: role implicit allow, allowing");
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true;
}
@@ -144,8 +147,7 @@ export class Guard {
(rolePermission) => rolePermission.permission.name === name,
);
debug &&
console.log("guard: rolePermission, allowing?", {
$console.debug("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,

View File

@@ -1,28 +1,66 @@
import { Exception } from "core";
import { Exception, isDebug } from "core";
import { HttpStatus } from "core/utils";
export class UserExistsException extends Exception {
export class AuthException extends Exception {
getSafeErrorAndCode() {
return {
error: "Invalid credentials",
code: HttpStatus.UNAUTHORIZED,
};
}
override toJSON(): any {
if (isDebug()) {
return super.toJSON();
}
return {
error: this.getSafeErrorAndCode().error,
type: "AuthException",
};
}
}
export class UserExistsException extends AuthException {
override name = "UserExistsException";
override code = 422;
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor() {
super("User already exists");
}
}
export class UserNotFoundException extends Exception {
export class UserNotFoundException extends AuthException {
override name = "UserNotFoundException";
override code = 404;
override code = HttpStatus.NOT_FOUND;
constructor() {
super("User not found");
}
}
export class InvalidCredentialsException extends Exception {
export class InvalidCredentialsException extends AuthException {
override name = "InvalidCredentialsException";
override code = 401;
override code = HttpStatus.UNAUTHORIZED;
constructor() {
super("Invalid credentials");
}
}
export class UnableToCreateUserException extends AuthException {
override name = "UnableToCreateUserException";
override code = HttpStatus.INTERNAL_SERVER_ERROR;
constructor() {
super("Unable to create user");
}
}
export class InvalidConditionsException extends AuthException {
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor(message: string) {
super(message ?? "Invalid conditions");
}
}

View File

@@ -1,5 +1,4 @@
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
export { sha256 } from "./utils/hash";
export {
type ProfileExchange,
type Strategy,

View File

@@ -1,13 +0,0 @@
// @deprecated: moved to @bknd/core
export async function sha256(password: string, salt?: string) {
// 1. Convert password to Uint8Array
const encoder = new TextEncoder();
const data = encoder.encode((salt ?? "") + password);
// 2. Hash the data using SHA-256
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
// 3. Convert hash to hex string for easier display
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

View File

@@ -8,6 +8,8 @@ export const config: CliCommand = (program) => {
.option("--pretty", "pretty print")
.action((options) => {
const config = getDefaultConfig();
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
});
};

View File

@@ -32,5 +32,6 @@ async function action(options: { out?: string; clean?: boolean }) {
// delete ".vite" directory in out
await fs.rm(path.resolve(out, ".vite"), { recursive: true });
// biome-ignore lint/suspicious/noConsoleLog:
console.log(c.green(`Assets copied to: ${c.bold(out)}`));
}

View File

@@ -43,10 +43,12 @@ export const create: CliCommand = (program) => {
function errorOutro() {
$p.outro(color.red("Failed to create project."));
// biome-ignore lint/suspicious/noConsoleLog:
console.log(
color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") +
color.cyan("https://github.com/bknd-io/bknd/issues"),
);
// biome-ignore lint/suspicious/noConsoleLog:
console.log("");
process.exit(1);
}
@@ -55,7 +57,14 @@ async function onExit() {
await flush();
}
async function action(options: { template?: string; dir?: string; integration?: string, yes?: boolean, clean?: boolean }) {
async function action(options: {
template?: string;
dir?: string;
integration?: string;
yes?: boolean;
clean?: boolean;
}) {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("");
const $t = createScoped("create");
$t.capture("start", {
@@ -96,10 +105,12 @@ async function action(options: { template?: string; dir?: string; integration?:
$t.properties.at = "dir";
if (fs.existsSync(downloadOpts.dir)) {
const clean = options.clean ?? await $p.confirm({
const clean =
options.clean ??
(await $p.confirm({
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
initialValue: false,
});
}));
if ($p.isCancel(clean)) {
await onExit();
process.exit(1);
@@ -174,8 +185,6 @@ async function action(options: { template?: string; dir?: string; integration?:
process.exit(1);
}
//console.log("integration", { type, integration });
const choices = templates.filter((t) => t.integration === integration);
if (choices.length === 0) {
await onExit();
@@ -261,9 +270,11 @@ async function action(options: { template?: string; dir?: string; integration?:
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
{
const install = options.yes ?? await $p.confirm({
const install =
options.yes ??
(await $p.confirm({
message: "Install dependencies?",
});
}));
if ($p.isCancel(install)) {
await onExit();

View File

@@ -17,6 +17,7 @@ export const debug: CliCommand = (program) => {
const subjects = {
paths: async () => {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[PATHS]", {
rootpath: getRootPath(),
distPath: getDistPath(),
@@ -27,6 +28,7 @@ const subjects = {
});
},
routes: async () => {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[APP ROUTES]");
const credentials = getConnectionCredentialsFromEnv();
const app = createApp({ connection: credentials });

View File

@@ -5,3 +5,4 @@ export { debug } from "./debug";
export { user } from "./user";
export { create } from "./create";
export { copyAssets } from "./copy-assets";
export { types } from "./types";

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import type { Config } from "@libsql/client/node";
import { config } from "core";
import { $console, config } from "core";
import type { MiddlewareHandler } from "hono";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -37,7 +37,7 @@ export async function startServer(
options: { port: number; open?: boolean },
) {
const port = options.port;
console.log(`Using ${server} serve`);
$console.log(`Using ${server} serve`);
switch (server) {
case "node": {
@@ -59,7 +59,8 @@ export async function startServer(
}
const url = `http://localhost:${port}`;
console.info("Server listening on", url);
$console.info("Server listening on", url);
if (options.open) {
await open(url);
}

View File

@@ -8,6 +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));
});
};

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,37 @@
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys";
import c from "picocolors";
export const types: CliCommand = (program) => {
program
.command("types")
.description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true))
.action(action);
};
async function action({
outfile,
write,
}: {
outfile: string;
write: boolean;
}) {
const app = await makeAppFromEnv({
server: "node",
});
await app.build();
const et = new EntityTypescript(app.em);
if (write) {
await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
}
}

View File

@@ -169,6 +169,7 @@ async function token(app: App, options: any) {
}
$log.info(`User found: ${c.cyan(user.email)}`);
// biome-ignore lint/suspicious/noConsoleLog:
console.log(
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
);

View File

@@ -1,5 +1,6 @@
import { $console } from "core";
import { execSync, exec as nodeExec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import path from "node:path";
import url from "node:url";
@@ -48,6 +49,16 @@ export async function fileExists(filePath: string) {
}
}
export async function writeFile(filePath: string, content: string) {
try {
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
return true;
} catch (e) {
$console.error("Failed to write file", e);
return false;
}
}
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, {

View File

@@ -29,7 +29,6 @@ export class AwsClient extends Aws4fetchClient {
}
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string {
//console.log("super:getUrl", path, searchParamsObj);
const url = new URL(path);
const converted = this.convertParams(searchParamsObj);
Object.entries(converted).forEach(([key, value]) => {
@@ -76,8 +75,6 @@ export class AwsClient extends Aws4fetchClient {
}
const raw = await response.text();
//console.log("raw", raw);
//console.log(JSON.stringify(xmlToObject(raw), null, 2));
return xmlToObject(raw) as T;
}

View File

@@ -3,7 +3,11 @@
*/
import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
export interface AppEntity<IdType extends number = number> {
id: PrimaryFieldType<IdType>;
}
export interface DB {
// make sure to make unknown as "any"

View File

@@ -1,9 +1,12 @@
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { HttpStatus } from "./utils/reqres";
export class Exception extends Error {
code = 400;
code: ContentfulStatusCode = HttpStatus.BAD_REQUEST;
override name = "Exception";
protected _context = undefined;
constructor(message: string, code?: number) {
constructor(message: string, code?: ContentfulStatusCode) {
super(message);
if (code) {
this.code = code;

View File

@@ -14,6 +14,10 @@ export abstract class Event<Params = any, Returning = void> {
params: Params;
returned: boolean = false;
/**
* Shallow validation of the event return
* It'll be deeply validated on the place where it is called
*/
validate(value: Returning): Event<Params, Returning> | void {
throw new EventReturnedWithoutValidation(this as any, value);
}

View File

@@ -1,5 +1,6 @@
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
import { $console } from "core";
export type RegisterListenerConfig =
| ListenerMode
@@ -83,10 +84,6 @@ export class EventManager<
} else {
// @ts-expect-error
slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug;
/*eventOrSlug instanceof Event
? // @ts-expect-error slug is static
eventOrSlug.constructor.slug
: eventOrSlug.slug;*/
}
return !!this.events.find((e) => slug === e.slug);
@@ -128,8 +125,7 @@ export class EventManager<
if (listener.id) {
const existing = this.listeners.find((l) => l.id === listener.id);
if (existing) {
// @todo: add a verbose option?
//console.warn(`Listener with id "${listener.id}" already exists.`);
$console.debug(`Listener with id "${listener.id}" already exists.`);
return this;
}
}
@@ -191,7 +187,7 @@ export class EventManager<
// @ts-expect-error slug is static
const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
$console.debug("EventManager disabled, not emitting", slug);
return event;
}
@@ -240,7 +236,7 @@ export class EventManager<
} catch (e) {
if (e instanceof InvalidEventReturn) {
this.options?.onInvalidReturn?.(_event, e);
console.warn(`Invalid return of event listener for "${slug}": ${e.message}`);
$console.warn(`Invalid return of event listener for "${slug}": ${e.message}`);
} else if (this.options?.onError) {
this.options.onError(_event, e);
} else {

View File

@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors";
export { isDebug, env } from "./env";
export { type PrimaryFieldType, config, type DB } from "./config";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
export { AwsClient } from "./clients/aws/AwsClient";
export {
SimpleRenderer,
@@ -25,8 +25,10 @@ export {
isBooleanLike,
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export * from "./console";
export * from "./events";
// compatibility
export type Middleware = MiddlewareHandler<any, any, any>;

View File

@@ -73,6 +73,7 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true,
skipMark: this.isForceParse(),
});
// regardless of "noEmit" this should always be triggered
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
@@ -122,19 +123,15 @@ export class SchemaObject<Schema extends TObject> {
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
//console.log("---alt:new", _jsonp(mergeObject(current, partial)));
const config = mergeObjectWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue;
}
});
//console.log("---new", _jsonp(config));
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => {
// only prepend path if given
@@ -149,7 +146,6 @@ export class SchemaObject<Schema extends TObject> {
}
});
});
//console.log("overwritePaths", keys, overwritePaths);
if (overwritePaths.length > 0) {
// filter out less specific paths (but only if more than 1)
@@ -157,12 +153,10 @@ export class SchemaObject<Schema extends TObject> {
overwritePaths.length > 1
? overwritePaths.filter((k) =>
overwritePaths.some((k2) => {
//console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
return k2 !== k && k2.startsWith(k);
}),
)
: overwritePaths;
//console.log("specific", specific);
for (const p of specific) {
set(config, p, get(partial, p));
@@ -170,8 +164,6 @@ export class SchemaObject<Schema extends TObject> {
}
}
//console.log("patch", _jsonp({ path, value, partial, config, current }));
const newConfig = await this.set(config);
return [partial, newConfig];
}
@@ -181,14 +173,11 @@ export class SchemaObject<Schema extends TObject> {
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = set(current, path, value);
//console.log("overwrite", { path, value, partial, config, current });
const newConfig = await this.set(config);
return [partial, newConfig];
}
@@ -198,7 +187,6 @@ export class SchemaObject<Schema extends TObject> {
if (p.length > 1) {
const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) {
//console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`);
}
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { SimpleRenderer } from "core";
describe(SimpleRenderer, () => {
const renderer = new SimpleRenderer(
{
name: "World",
views: 123,
nested: {
foo: "bar",
baz: ["quz", "foo"],
},
someArray: [1, 2, 3],
enabled: true,
},
{
renderKeys: true,
},
);
test("strings", async () => {
const tests = [
["Hello {{ name }}, count: {{views}}", "Hello World, count: 123"],
["Nested: {{nested.foo}}", "Nested: bar"],
["Nested: {{nested.baz[0]}}", "Nested: quz"],
] as const;
for (const [template, expected] of tests) {
expect(await renderer.renderString(template)).toEqual(expected);
}
});
test("arrays", async () => {
const tests = [
[
["{{someArray[0]}}", "{{someArray[1]}}", "{{someArray[2]}}"],
["1", "2", "3"],
],
] as const;
for (const [template, expected] of tests) {
const result = await renderer.render(template);
expect(result).toEqual(expected as any);
}
});
test("objects", async () => {
const tests = [
[
{
foo: "{{name}}",
bar: "{{views}}",
baz: "{{nested.foo}}",
quz: "{{nested.baz[0]}}",
},
{
foo: "World",
bar: "123",
baz: "bar",
quz: "quz",
},
],
] as const;
for (const [template, expected] of tests) {
const result = await renderer.render(template);
expect(result).toEqual(expected as any);
}
});
});

View File

@@ -1,17 +1,13 @@
import { Liquid, LiquidError } from "liquidjs";
import type { RenderOptions } from "liquidjs/dist/liquid-options";
import { BkndError } from "../errors";
import { get } from "lodash-es";
export type TemplateObject = Record<string, string | Record<string, string>>;
export type TemplateTypes = string | TemplateObject;
export type TemplateTypes = string | TemplateObject | any;
export type SimpleRendererOptions = RenderOptions & {
export type SimpleRendererOptions = {
renderKeys?: boolean;
};
export class SimpleRenderer {
private engine = new Liquid();
constructor(
private variables: Record<string, any> = {},
private options: SimpleRendererOptions = {},
@@ -22,7 +18,6 @@ export class SimpleRenderer {
}
static hasMarkup(template: string | object): boolean {
//console.log("has markup?", template);
let flat: string = "";
if (Array.isArray(template) || typeof template === "object") {
@@ -34,49 +29,29 @@ export class SimpleRenderer {
flat = String(template);
}
//console.log("** flat", flat);
const checks = ["{{", "{%", "{#", "{:"];
const hasMarkup = checks.some((check) => flat.includes(check));
//console.log("--has markup?", hasMarkup);
return hasMarkup;
const checks = ["{{"];
return checks.some((check) => flat.includes(check));
}
async render<Given extends TemplateTypes>(template: Given): Promise<Given> {
try {
async render<Given extends TemplateTypes = TemplateTypes>(template: Given): Promise<Given> {
if (typeof template === "undefined" || template === null) return template;
if (typeof template === "string") {
return (await this.renderString(template)) as unknown as Given;
} else if (Array.isArray(template)) {
return (await Promise.all(
template.map((item) => this.render(item)),
)) as unknown as Given;
return (await Promise.all(template.map((item) => this.render(item)))) as unknown as Given;
} else if (typeof template === "object") {
return (await this.renderObject(template)) as unknown as Given;
}
} catch (e) {
if (e instanceof LiquidError) {
const details = {
name: e.name,
token: {
kind: e.token.kind,
input: e.token.input,
begin: e.token.begin,
end: e.token.end,
},
};
throw new BkndError(e.message, details, "liquid");
}
throw e;
return (await this.renderObject(template as any)) as unknown as Given;
}
throw new Error("Invalid template type");
}
async renderString(template: string): Promise<string> {
//console.log("*** renderString", template, this.variables);
return this.engine.parseAndRender(template, this.variables, this.options);
return template.replace(/{{\s*([^{}]+?)\s*}}/g, (_, expr: string) => {
const value = get(this.variables, expr.trim());
return value == null ? "" : String(value);
});
}
async renderObject(template: TemplateObject): Promise<TemplateObject> {

View File

@@ -9,13 +9,11 @@ export class DebugLogger {
}
context(context: string) {
//console.log("[ settings context ]", context, this._context);
this._context.push(context);
return this;
}
clear() {
//console.log("[ clear context ]", this._context.pop(), this._context);
this._context.pop();
return this;
}
@@ -33,6 +31,8 @@ export class DebugLogger {
const indents = " ".repeat(Math.max(this._context.length - 1, 0));
const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
// biome-ignore lint/suspicious/noConsoleLog: <explanation>
console.log(indents, context, time, ...args);
this.last = now;

View File

@@ -4,7 +4,6 @@ import weekOfYear from "dayjs/plugin/weekOfYear.js";
declare module "dayjs" {
interface Dayjs {
week(): number;
week(value: number): dayjs.Dayjs;
}
}

View File

@@ -2,6 +2,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
import { randomString } from "core/utils/strings";
import type { Context } from "hono";
import { invariant } from "core/utils/runtime";
import { $console } from "../console";
export function getContentName(request: Request): string | undefined;
export function getContentName(contentDisposition: string): string | undefined;
@@ -130,7 +131,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
return await blobToFile(v);
}
} catch (e) {
console.warn("Error parsing form data", e);
$console.warn("Error parsing form data", e);
}
} else {
try {
@@ -141,7 +142,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType });
}
} catch (e) {
console.warn("Error parsing blob", e);
$console.warn("Error parsing blob", e);
}
}

View File

@@ -359,3 +359,50 @@ export function getPath(
throw new Error(`Invalid path: ${path.join(".")}`);
}
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
const openPad = pad(_level + 1);
const closePad = pad(_level);
// primitives
if (value === null) return "null";
if (value === undefined) return "undefined";
const t = typeof value;
if (t === "string") return JSON.stringify(value); // handles escapes
if (t === "number" || t === "boolean") return String(value);
// arrays
if (Array.isArray(value)) {
const out = value
.map((v) => objectToJsLiteral(v, indent, _level + 1))
.join(", " + (indent ? nl + openPad : ""));
return (
"[" +
(indent && value.length ? nl + openPad : "") +
out +
(indent && value.length ? nl + closePad : "") +
"]"
);
}
// objects
if (t === "object") {
const entries = Object.entries(value).map(([k, v]) => {
const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier?
const key = idOk ? k : JSON.stringify(k); // quote if needed
return key + ": " + objectToJsLiteral(v, indent, _level + 1);
});
const out = entries.join(", " + (indent ? nl + openPad : ""));
return (
"{" +
(indent && entries.length ? nl + openPad : "") +
out +
(indent && entries.length ? nl + closePad : "") +
"}"
);
}
throw new TypeError(`Unsupported data type: ${t}`);
}

View File

@@ -1,7 +1,3 @@
import { randomString } from "core/utils/strings";
import type { Context } from "hono";
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
export function headersToObject(headers: Headers): Record<string, string> {
if (!headers) return {};
return { ...Object.fromEntries(headers.entries()) };
@@ -102,7 +98,7 @@ export function decodeSearch(str) {
export const enum HttpStatus {
// Informational responses (100199)
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
//SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
EARLY_HINTS = 103,
@@ -111,8 +107,8 @@ export const enum HttpStatus {
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
//NO_CONTENT = 204,
//RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
ALREADY_REPORTED = 208,
@@ -123,7 +119,7 @@ export const enum HttpStatus {
MOVED_PERMANENTLY = 301,
FOUND = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
//NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
@@ -172,3 +168,13 @@ export const enum HttpStatus {
NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}
// biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum HttpStatusEmpty {
// Informational responses (100199)
SWITCHING_PROTOCOLS = 101,
// Successful responses (200299)
NO_CONTENT = 204,
RESET_CONTENT = 205,
// Redirection messages (300399)
NOT_MODIFIED = 304,
}

View File

@@ -132,3 +132,8 @@ export function slugify(str: string): string {
.replace(/-+/g, "-") // remove consecutive hyphens
);
}
export function truncate(str: string, length = 50, end = "..."): string {
if (str.length <= length) return str;
return str.substring(0, length) + end;
}

View File

@@ -1,18 +1,11 @@
import {
Kind,
type ObjectOptions,
type SchemaOptions,
type Static,
type StaticDecode,
type StringOptions,
type TLiteral,
type TLiteralValue,
type TObject,
type TRecord,
type TSchema,
type TString,
Type,
import * as tb from "@sinclair/typebox";
import type {
TypeRegistry,
Static,
StaticDecode,
TSchema,
SchemaOptions,
TObject,
} from "@sinclair/typebox";
import {
DefaultErrorFunction,
@@ -43,7 +36,7 @@ const validationSymbol = Symbol("tb-parse-validation");
export class TypeInvalidError extends Error {
errors: ValueError[];
constructor(
public schema: TSchema,
public schema: tb.TSchema,
public data: unknown,
message?: string,
) {
@@ -92,29 +85,28 @@ export function mark(obj: any, validated = true) {
}
}
export function parse<Schema extends TSchema = TSchema>(
export function parse<Schema extends tb.TSchema = tb.TSchema>(
schema: Schema,
data: RecursivePartial<Static<Schema>>,
data: RecursivePartial<tb.Static<Schema>>,
options?: ParseOptions,
): Static<Schema> {
): tb.Static<Schema> {
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
if (options?.useDefaults === false) {
return data as Static<typeof schema>;
return data as tb.Static<typeof schema>;
}
// this is important as defaults are expected
return Default(schema, data as any) as Static<Schema>;
return Default(schema, data as any) as tb.Static<Schema>;
}
const parsed = options?.useDefaults === false ? data : Default(schema, data);
if (Check(schema, parsed)) {
options?.skipMark !== true && mark(parsed, true);
return parsed as Static<typeof schema>;
return parsed as tb.Static<typeof schema>;
} else if (options?.onError) {
options.onError(Errors(schema, data));
} else {
//console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2));
throw new TypeInvalidError(schema, data);
}
@@ -122,26 +114,24 @@ export function parse<Schema extends TSchema = TSchema>(
return undefined as any;
}
export function parseDecode<Schema extends TSchema = TSchema>(
export function parseDecode<Schema extends tb.TSchema = tb.TSchema>(
schema: Schema,
data: RecursivePartial<StaticDecode<Schema>>,
): StaticDecode<Schema> {
//console.log("parseDecode", schema, data);
data: RecursivePartial<tb.StaticDecode<Schema>>,
): tb.StaticDecode<Schema> {
const parsed = Default(schema, data);
if (Check(schema, parsed)) {
return parsed as StaticDecode<typeof schema>;
return parsed as tb.StaticDecode<typeof schema>;
}
//console.log("errors", ...Errors(schema, data));
throw new TypeInvalidError(schema, data);
}
export function strictParse<Schema extends TSchema = TSchema>(
export function strictParse<Schema extends tb.TSchema = tb.TSchema>(
schema: Schema,
data: Static<Schema>,
data: tb.Static<Schema>,
options?: ParseOptions,
): Static<Schema> {
): tb.Static<Schema> {
return parse(schema, data as any, options);
}
@@ -150,11 +140,14 @@ export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) {
return typeof value === "string" && schema.enum.includes(value);
});
}
registerCustomTypeboxKinds(TypeRegistry);
registerCustomTypeboxKinds(tb.TypeRegistry);
export const StringEnum = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
Type.Unsafe<T[number]>({
[Kind]: "StringEnum",
export const StringEnum = <const T extends readonly string[]>(
values: T,
options?: tb.StringOptions,
) =>
tb.Type.Unsafe<T[number]>({
[tb.Kind]: "StringEnum",
type: "string",
enum: values,
...options,
@@ -162,45 +155,47 @@ export const StringEnum = <const T extends readonly string[]>(values: T, options
// key value record compatible with RJSF and typebox inference
// acting like a Record, but using an Object with additionalProperties
export const StringRecord = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
TString,
export const StringRecord = <T extends tb.TSchema>(properties: T, options?: tb.ObjectOptions) =>
tb.Type.Object({}, { ...options, additionalProperties: properties }) as unknown as tb.TRecord<
tb.TString,
typeof properties
>;
// fixed value that only be what is given + prefilled
export const Const = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
export const Const = <T extends tb.TLiteralValue = tb.TLiteralValue>(
value: T,
options?: tb.SchemaOptions,
) =>
tb.Type.Literal(value, {
...options,
default: value,
const: value,
readOnly: true,
}) as tb.TLiteral<T>;
export const StringIdentifier = Type.String({
export const StringIdentifier = tb.Type.String({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
minLength: 2,
maxLength: 150,
});
export const StrictObject = <T extends tb.TProperties>(
properties: T,
options?: tb.ObjectOptions,
): tb.TObject<T> => tb.Type.Object(properties, { ...options, additionalProperties: false });
SetErrorFunction((error) => {
if (error?.schema?.errorMessage) {
return error.schema.errorMessage;
}
if (error?.schema?.[Kind] === "StringEnum") {
if (error?.schema?.[tb.Kind] === "StringEnum") {
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
}
return DefaultErrorFunction(error);
});
export {
Type,
type Static,
type StaticDecode,
type TSchema,
Kind,
type TObject,
type ValueError,
type SchemaOptions,
Value,
Default,
Errors,
Check,
};
export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions };
export { Value, Default, Errors, Check };

View File

@@ -1,5 +1,6 @@
import type { DB } from "core";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import type { Insertable, Selectable, Updateable } from "kysely";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
@@ -25,21 +26,23 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}
}
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
readOne<E extends keyof DB | string>(
entity: E,
id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
["entity", entity as any, id],
query,
);
}
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
readOneBy<E extends keyof DB | string>(
entity: E,
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
return this.readMany(entity, {
...query,
@@ -48,10 +51,8 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
}
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: RepoQueryIn = {},
) {
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery;
@@ -64,68 +65,70 @@ export class DataApi extends ModuleApi<DataApiOptions> {
return this.post<T>(["entity", entity as any, "query"], input);
}
readManyByReference<
E extends keyof DB | string,
R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData,
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>(
entity: E,
id: PrimaryFieldType,
reference: R,
query: RepoQueryIn = {},
) {
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
["entity", entity as any, id, reference],
query ?? this.options.defaultQuery,
);
}
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">,
input: Insertable<Input>,
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
}
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">[],
input: Insertable<Input>[],
) {
if (!input || !Array.isArray(input) || input.length === 0) {
throw new Error("input is required");
}
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
}
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>,
input: Updateable<Input>,
) {
if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
}
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
update: Partial<Omit<Data, "id">>,
update: Updateable<Input>,
) {
this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
update,
where,
});
}
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
) {
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
}
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
) {
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
}

View File

@@ -1,5 +1,6 @@
import { isDebug, tbValidator as tb } from "core";
import { StringEnum, Type } from "core/utils";
import { $console, isDebug, tbValidator as tb } from "core";
import { StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import {
DataPermissions,
type EntityData,
@@ -14,6 +15,7 @@ import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
const { Type } = tbbox;
export class DataController extends Controller {
constructor(
@@ -45,7 +47,6 @@ export class DataController extends Controller {
const template = { data: res.data, meta };
// @todo: this works but it breaks in FE (need to improve DataTable)
//return objectCleanEmpty(template) as any;
// filter empty
return Object.fromEntries(
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null),
@@ -56,7 +57,6 @@ export class DataController extends Controller {
const template = { data: res.data };
// filter empty
//return objectCleanEmpty(template);
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
}
@@ -72,11 +72,6 @@ export class DataController extends Controller {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
const func = h;
@@ -141,10 +136,8 @@ export class DataController extends Controller {
}),
),
async (c) => {
//console.log("request", c.req.raw);
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return this.notFound(c);
}
const _entity = this.em.entity(entity);
@@ -254,7 +247,6 @@ export class DataController extends Controller {
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
console.warn("not found:", entity, definedEntities);
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
@@ -328,7 +320,6 @@ export class DataController extends Controller {
return this.notFound(c);
}
const options = (await c.req.valid("json")) as RepoQuery;
//console.log("options", options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });

View File

@@ -38,7 +38,7 @@ export class LibsqlConnection extends SqliteConnection {
if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
console.log("changing protocol to", protocol);
$console.log("changing protocol to", protocol);
const [, rest] = url.split("://");
url = `${protocol}://${rest}`;
}

View File

@@ -1,4 +1,5 @@
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
import { type Static, StringRecord, objectTransform } from "core/utils";
import * as tb from "@sinclair/typebox";
import {
FieldClassMap,
RelationClassMap,
@@ -18,36 +19,37 @@ export type FieldType = keyof typeof FIELDS;
export const RELATIONS = RelationClassMap;
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
return Type.Object(
return tb.Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
config: Type.Optional(field.schema),
type: tb.Type.Const(name, { default: name, readOnly: true }),
config: tb.Type.Optional(field.schema),
},
{
title: name,
},
);
});
export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
export const fieldsSchema = tb.Type.Union(Object.values(fieldsSchemaObject));
export const entityFields = StringRecord(fieldsSchema);
export type TAppDataField = Static<typeof fieldsSchema>;
export type TAppDataEntityFields = Static<typeof entityFields>;
export const entitiesSchema = Type.Object({
//name: Type.String(),
type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })),
config: Type.Optional(entityConfigSchema),
fields: Type.Optional(entityFields),
export const entitiesSchema = tb.Type.Object({
type: tb.Type.Optional(
tb.Type.String({ enum: entityTypes, default: "regular", readOnly: true }),
),
config: tb.Type.Optional(entityConfigSchema),
fields: tb.Type.Optional(entityFields),
});
export type TAppDataEntity = Static<typeof entitiesSchema>;
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
return Type.Object(
return tb.Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
source: Type.String(),
target: Type.String(),
config: Type.Optional(relationClass.schema),
type: tb.Type.Const(name, { default: name, readOnly: true }),
source: tb.Type.String(),
target: tb.Type.String(),
config: tb.Type.Optional(relationClass.schema),
},
{
title: name,
@@ -56,24 +58,23 @@ export const relationsSchema = Object.entries(RelationClassMap).map(([name, rela
});
export type TAppDataRelation = Static<(typeof relationsSchema)[number]>;
export const indicesSchema = Type.Object(
export const indicesSchema = tb.Type.Object(
{
entity: Type.String(),
fields: Type.Array(Type.String(), { minItems: 1 }),
//name: Type.Optional(Type.String()),
unique: Type.Optional(Type.Boolean({ default: false })),
entity: tb.Type.String(),
fields: tb.Type.Array(tb.Type.String(), { minItems: 1 }),
unique: tb.Type.Optional(tb.Type.Boolean({ default: false })),
},
{
additionalProperties: false,
},
);
export const dataConfigSchema = Type.Object(
export const dataConfigSchema = tb.Type.Object(
{
basepath: Type.Optional(Type.String({ default: "/api/data" })),
entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })),
indices: Type.Optional(StringRecord(indicesSchema, { default: {} })),
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
},
{
additionalProperties: false,

View File

@@ -1,13 +1,14 @@
import { config } from "core";
import { $console, config } from "core";
import {
type Static,
StringEnum,
Type,
parse,
snakeToPascalWithSpaces,
transformObject,
} from "core/utils";
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
// @todo: entity must be migrated to typebox
export const entityConfigSchema = Type.Object(
@@ -183,9 +184,9 @@ export class Entity<
if (existing) {
// @todo: for now adding a graceful method
if (JSON.stringify(existing) === JSON.stringify(field)) {
/*console.warn(
$console.warn(
`Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`,
);*/
);
return;
}
@@ -231,8 +232,14 @@ export class Entity<
}
for (const field of fields) {
if (!field.isValid(data[field.name], context)) {
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
if (!field.isValid(data?.[field.name], context)) {
$console.warn(
"invalid data given for",
this.name,
context,
field.name,
data[field.name],
);
if (options?.explain) {
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
}
@@ -258,7 +265,6 @@ export class Entity<
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
const schema = Type.Object(
transformObject(_fields, (field) => {
//const hidden = field.isHidden(options?.context);
const fillable = field.isFillable(options?.context);
return {
title: field.config.label,
@@ -274,11 +280,18 @@ export class Entity<
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
toTypes() {
return {
name: this.name,
type: this.type,
comment: this.config.description,
fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])),
};
}
toJSON() {
return {
//name: this.name,
type: this.type,
//fields: transformObject(this.fields, (field) => field.toJSON()),
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
config: this.config,
};

View File

@@ -1,4 +1,4 @@
import type { DB as DefaultDB } from "core";
import { $console, type DB as DefaultDB } from "core";
import { EventManager } from "core/events";
import { sql } from "kysely";
import { Connection } from "../connection/Connection";
@@ -55,7 +55,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
this.connection = connection;
this.emgr = emgr ?? new EventManager();
//console.log("registering events", EntityManager.Events);
this.emgr.registerEvents(EntityManager.Events);
}
@@ -90,7 +89,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
if (existing) {
// @todo: for now adding a graceful method
if (JSON.stringify(existing) === JSON.stringify(entity)) {
//console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`);
$console.warn(
`Entity "${entity.name}" already exists, but it's the same, skipping adding it.`,
);
return;
}
@@ -108,7 +109,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
}
this._entities[entityIndex] = entity;
// caused issues because this.entity() was using a reference (for when initial config was given)
}
@@ -295,7 +295,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
return {
entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])),
relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])),
//relations: this.relations.all.map((r) => r.toJSON()),
indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()])),
};
}

View File

@@ -0,0 +1,228 @@
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
import { autoFormatString } from "core/utils";
import { AppAuth, AppMedia } from "modules";
export type TEntityTSType = {
name: string;
type: TEntityType;
comment?: string;
fields: Record<string, TFieldTSType>;
};
// [select, insert, update]
type TFieldContextType = boolean | [boolean, boolean, boolean];
export type TFieldTSType = {
required?: TFieldContextType;
fillable?: TFieldContextType;
type: "PrimaryFieldType" | string;
comment?: string;
import?: {
package: string;
name: string;
}[];
};
export type EntityTypescriptOptions = {
indentWidth?: number;
indentChar?: string;
entityCommentMultiline?: boolean;
fieldCommentMultiline?: boolean;
};
// keep a local copy here until properties have a type
const systemEntities = {
users: AppAuth.usersFields,
media: AppMedia.mediaFields,
};
export class EntityTypescript {
constructor(
protected em: EntityManager,
protected _options: EntityTypescriptOptions = {},
) {}
get options() {
return {
...this._options,
indentWidth: 2,
indentChar: " ",
entityCommentMultiline: true,
fieldCommentMultiline: false,
};
}
toTypes() {
return this.em.entities.map((e) => e.toTypes());
}
protected getTab(count = 1) {
return this.options.indentChar.repeat(this.options.indentWidth).repeat(count);
}
collectImports(
type: TEntityTSType,
imports: Record<string, string[]> = {},
): Record<string, string[]> {
for (const [, entity_type] of Object.entries(type.fields)) {
for (const imp of entity_type.import ?? []) {
const name = imp.name;
const pkg = imp.package;
if (!imports[pkg]) {
imports[pkg] = [];
}
if (!imports[pkg].includes(name)) {
imports[pkg].push(name);
}
}
}
return imports;
}
typeName(name: string) {
return autoFormatString(name);
}
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
let string = "";
const coment_multiline = this.options.fieldCommentMultiline;
const indent = opts?.indent ?? 1;
for (const [field_name, field_type] of Object.entries(type.fields)) {
if (opts?.ignore_fields?.includes(field_name)) continue;
let f = "";
f += this.commentString(field_type.comment, indent, coment_multiline);
f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `;
f += field_type.type + ";";
f += "\n";
string += f;
}
return string;
}
relationToFieldType(relation: EntityRelation, entity: Entity) {
const other = relation.other(entity);
const listable = relation.isListableFor(entity);
const name = this.typeName(other.entity.name);
let type = name;
if (other.entity.type === "system") {
type = `DB["${other.entity.name}"]`;
}
return {
fields: {
[other.reference]: {
required: false,
type: `${type}${listable ? "[]" : ""}`,
},
},
};
}
importsToString(imports: Record<string, string[]>) {
const strings: string[] = [];
for (const [pkg, names] of Object.entries(imports)) {
strings.push(`import type { ${names.join(", ")} } from "${pkg}";`);
}
return strings;
}
commentString(comment?: string, indents = 0, multiline = true) {
if (!comment) return "";
const indent = this.getTab(indents);
if (!multiline) return `${indent}// ${comment}\n`;
return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`;
}
entityToTypeString(
entity: Entity,
opts?: { ignore_fields?: string[]; indent?: number; export?: boolean },
) {
const type = entity.toTypes();
const name = this.typeName(type.name);
const indent = opts?.indent ?? 1;
const min_indent = Math.max(0, indent - 1);
let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline);
s += `${opts?.export ? "export " : ""}interface ${name} {\n`;
s += this.fieldTypesToString(type, opts);
// add listable relations
const relations = this.em.relations.relationsOf(entity);
const rel_types = relations.map((r) =>
this.relationToFieldType(r, entity),
) as TEntityTSType[];
for (const rel_type of rel_types) {
s += this.fieldTypesToString(rel_type, {
indent,
});
}
s += `${this.getTab(min_indent)}}`;
return s;
}
toString() {
const strings: string[] = [];
const tables: Record<string, string> = {};
const imports: Record<string, string[]> = {
"bknd/core": ["DB"],
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
};
// add global types
let g = "declare global {\n";
g += `${this.getTab(1)}type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;\n`;
g += `${this.getTab(1)}type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;\n`;
g += `${this.getTab(1)}type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;\n`;
g += "}";
strings.push(g);
const system_entities = this.em.entities.filter((e) => e.type === "system");
for (const entity of this.em.entities) {
// skip system entities, declare addtional props in the DB interface
if (system_entities.includes(entity)) continue;
const type = entity.toTypes();
if (!type) continue;
this.collectImports(type, imports);
tables[type.name] = this.typeName(type.name);
const s = this.entityToTypeString(entity, {
export: true,
});
strings.push(s);
}
// write tables
let tables_string = "interface Database {\n";
for (const [name, type] of Object.entries(tables)) {
tables_string += `${this.getTab(1)}${name}: ${type};\n`;
}
tables_string += "}";
strings.push(tables_string);
// merge
let merge = `declare module "bknd/core" {\n`;
for (const systemEntity of system_entities) {
const system_fields = Object.keys(systemEntities[systemEntity.name]);
const additional_fields = systemEntity.fields
.filter((f) => !system_fields.includes(f.name) && f.type !== "primary")
.map((f) => f.name);
if (additional_fields.length === 0) continue;
merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, {
ignore_fields: ["id", ...system_fields],
indent: 2,
})}\n\n`;
}
merge += `${this.getTab(1)}interface DB extends Database {}\n}`;
strings.push(merge);
const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")];
return final.join("\n\n");
}
}

View File

@@ -1,4 +1,4 @@
import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from "..";
@@ -72,7 +72,6 @@ export class Mutator<
// if relation field (include key and value in validatedData)
if (Array.isArray(result)) {
//console.log("--- (instructions)", result);
const [relation_key, relation_value] = result;
validatedData[relation_key] = relation_value;
}
@@ -122,7 +121,7 @@ export class Mutator<
};
} catch (e) {
// @todo: redact
console.log("[Error in query]", sql);
$console.error("[Error in query]", sql);
throw e;
}
}

View File

@@ -302,24 +302,38 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}
}
private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> {
const event =
options.limit === 1
? Repository.Events.RepositoryFindOneBefore
: Repository.Events.RepositoryFindManyBefore;
await this.emgr.emit(new event({ entity, options }));
}
private async triggerFindAfter(
entity: Entity,
options: RepoQuery,
data: EntityData[],
): Promise<void> {
if (options.limit === 1) {
await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }),
);
} else {
await this.emgr.emit(
new Repository.Events.RepositoryFindManyAfter({ entity, options, data }),
);
}
}
protected async single(
qb: RepositoryQB,
options: RepoQuery,
): Promise<RepositoryResponse<EntityData>> {
await this.emgr.emit(
new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options }),
);
await this.triggerFindBefore(this.entity, options);
const { data, ...response } = await this.performQuery(qb);
await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({
entity: this.entity,
options,
data: data[0]!,
}),
);
await this.triggerFindAfter(this.entity, options, data);
return { ...response, data: data[0]! };
}
@@ -420,26 +434,16 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
limit: 1,
});
return this.single(qb, options) as any;
return (await this.single(qb, options)) as any;
}
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options);
await this.emgr.emit(
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options }),
);
await this.triggerFindBefore(this.entity, options);
const res = await this.performQuery(qb);
await this.emgr.emit(
new Repository.Events.RepositoryFindManyAfter({
entity: this.entity,
options,
data: res.data,
}),
);
await this.triggerFindAfter(this.entity, options, res.data);
return res as any;
}

View File

@@ -1,26 +1,26 @@
import { Exception } from "core";
import type { TypeInvalidError } from "core/utils";
import { HttpStatus, type TypeInvalidError } from "core/utils";
import type { Entity } from "./entities";
import type { Field } from "./fields";
export class UnableToConnectException extends Exception {
override name = "UnableToConnectException";
override code = 500;
override code = HttpStatus.INTERNAL_SERVER_ERROR;
}
export class InvalidSearchParamsException extends Exception {
override name = "InvalidSearchParamsException";
override code = 422;
override code = HttpStatus.UNPROCESSABLE_ENTITY;
}
export class TransformRetrieveFailedException extends Exception {
override name = "TransformRetrieveFailedException";
override code = 422;
override code = HttpStatus.UNPROCESSABLE_ENTITY;
}
export class TransformPersistFailedException extends Exception {
override name = "TransformPersistFailedException";
override code = 422;
override code = HttpStatus.UNPROCESSABLE_ENTITY;
static invalidType(property: string, expected: string, given: any) {
const givenValue = typeof given === "object" ? JSON.stringify(given) : given;
@@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception {
export class InvalidFieldConfigException extends Exception {
override name = "InvalidFieldConfigException";
override code = 400;
override code = HttpStatus.BAD_REQUEST;
constructor(
field: Field<any, any, any>,
@@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception {
export class EntityNotDefinedException extends Exception {
override name = "EntityNotDefinedException";
override code = 400;
override code = HttpStatus.BAD_REQUEST;
constructor(entity?: Entity | string) {
if (!entity) {
@@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception {
export class EntityNotFoundException extends Exception {
override name = "EntityNotFoundException";
override code = 404;
override code = HttpStatus.NOT_FOUND;
constructor(entity: Entity | string, id: any) {
super(

View File

@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core";
import { $console, type PrimaryFieldType } from "core";
import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "../server/data-query-impl";
@@ -9,6 +9,10 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat
override validate(data: EntityData) {
const { entity } = this.params;
if (!entity.isValidData(data, "create")) {
$console.warn("MutatorInsertBefore.validate: invalid", {
entity: entity.name,
data,
});
throw new InvalidEventReturn("EntityData", "invalid");
}
@@ -36,13 +40,18 @@ export class MutatorUpdateBefore extends Event<
static override slug = "mutator-update-before";
override validate(data: EntityData) {
const { entity, ...rest } = this.params;
const { entity, entityId } = this.params;
if (!entity.isValidData(data, "update")) {
$console.warn("MutatorUpdateBefore.validate: invalid", {
entity: entity.name,
entityId,
data,
});
throw new InvalidEventReturn("EntityData", "invalid");
}
return this.clone({
...rest,
entityId,
entity,
data,
});

View File

@@ -1,7 +1,9 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export const booleanFieldConfigSchema = Type.Composite([
Type.Object({
@@ -47,7 +49,6 @@ export class BooleanField<Required extends true | false = false> extends Field<
}
override transformRetrieve(value: unknown): boolean | null {
//console.log("Boolean:transformRetrieve:value", value);
if (typeof value === "undefined" || value === null) {
if (this.isRequired()) return false;
if (this.hasDefault()) return this.getDefault();
@@ -87,4 +88,11 @@ export class BooleanField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
}
override toType() {
return {
...super.toType(),
type: "boolean",
};
}
}

View File

@@ -1,11 +1,14 @@
import { type Static, StringEnum, Type, dayjs } from "core/utils";
import { type Static, StringEnum, dayjs } from "core/utils";
import type { EntityManager } from "../entities";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { $console } from "core";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const dateFieldConfigSchema = Type.Composite(
[
Type.Object({
//default_value: Type.Optional(Type.Date()),
type: StringEnum(["date", "datetime", "week"] as const, { default: "date" }),
timezone: Type.Optional(Type.String()),
min_date: Type.Optional(Type.String()),
@@ -51,13 +54,11 @@ export class DateField<Required extends true | false = false> extends Field<
}
private parseDateFromString(value: string): Date {
//console.log("parseDateFromString", value);
if (this.config.type === "week" && value.includes("-W")) {
const [year, week] = value.split("-W").map((n) => Number.parseInt(n, 10)) as [
number,
number,
];
//console.log({ year, week });
// @ts-ignore causes errors on build?
return dayjs().year(year).week(week).toDate();
}
@@ -67,15 +68,12 @@ export class DateField<Required extends true | false = false> extends Field<
override getValue(value: string, context?: TRenderContext): string | undefined {
if (value === null || !value) return;
//console.log("getValue", { value, context });
const date = this.parseDateFromString(value);
//console.log("getValue.date", date);
if (context === "submit") {
try {
return date.toISOString();
} catch (e) {
//console.warn("DateField.getValue:value/submit", value, e);
return undefined;
}
}
@@ -84,7 +82,7 @@ export class DateField<Required extends true | false = false> extends Field<
try {
return `${date.getFullYear()}-W${dayjs(date).week()}`;
} catch (e) {
console.warn("error - DateField.getValue:week", value, e);
$console.warn("DateField.getValue:week error", value, String(e));
return;
}
}
@@ -97,8 +95,7 @@ export class DateField<Required extends true | false = false> extends Field<
return this.formatDate(local);
} catch (e) {
console.warn("DateField.getValue:value", value);
console.warn("DateField.getValue:e", e);
$console.warn("DateField.getValue error", this.config.type, value, String(e));
return;
}
}
@@ -117,7 +114,6 @@ export class DateField<Required extends true | false = false> extends Field<
}
override transformRetrieve(_value: string): Date | null {
//console.log("transformRetrieve DateField", _value);
const value = super.transformRetrieve(_value);
if (value === null) return null;
@@ -136,7 +132,6 @@ export class DateField<Required extends true | false = false> extends Field<
const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value;
//console.log("transformPersist DateField", value);
switch (this.config.type) {
case "date":
case "week":
@@ -150,4 +145,11 @@ export class DateField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "Date | string",
};
}
}

View File

@@ -1,7 +1,10 @@
import { Const, type Static, StringEnum, StringRecord, Type } from "core/utils";
import { Const, type Static, StringEnum } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const enumFieldConfigSchema = Type.Composite(
[
@@ -53,10 +56,6 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
constructor(name: string, config: Partial<EnumFieldConfig>) {
super(name, config);
/*if (this.config.options.values.length === 0) {
throw new Error(`Enum field "${this.name}" requires at least one option`);
}*/
if (this.config.default_value && !this.isValidValue(this.config.default_value)) {
throw new Error(`Default value "${this.config.default_value}" is not a valid option`);
}
@@ -69,10 +68,6 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
getOptions(): { label: string; value: string }[] {
const options = this.config?.options ?? { type: "strings", values: [] };
/*if (options.values?.length === 0) {
throw new Error(`Enum field "${this.name}" requires at least one option`);
}*/
if (options.type === "strings") {
return options.values?.map((option) => ({ label: option, value: option }));
}
@@ -146,4 +141,14 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
}),
);
}
override toType(): TFieldTSType {
const union = this.getOptions().map(({ value }) =>
typeof value === "string" ? `"${value}"` : value,
);
return {
...super.toType(),
type: union.length > 0 ? union.join(" | ") : "string",
};
}
}

View File

@@ -4,13 +4,15 @@ import {
type Static,
StringEnum,
type TSchema,
Type,
TypeInvalidError,
} from "core/utils";
import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
import type { FieldSpec } from "data/connection/Connection";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails
@@ -184,12 +186,14 @@ export abstract class Field<
};
}
// @todo: add field level validation
isValid(value: any, context: TActionContext): boolean {
if (value) {
if (typeof value !== "undefined") {
return this.isFillable(context);
} else {
} else if (context === "create") {
return !this.isRequired();
}
return true;
}
/**
@@ -232,6 +236,14 @@ export abstract class Field<
return this.toSchemaWrapIfRequired(Type.Any());
}
toType(): TFieldTSType {
return {
required: this.isRequired(),
comment: this.getDescription(),
type: "any",
};
}
toJSON() {
return {
// @todo: current workaround because of fixed string type

View File

@@ -1,7 +1,10 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
@@ -82,7 +85,6 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
context: TActionContext,
): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context);
//console.log("value", value);
if (this.nullish(value)) return value;
if (!this.isSerializable(value)) {
@@ -97,4 +99,11 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
return JSON.stringify(value);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "any",
};
}
}

View File

@@ -1,8 +1,11 @@
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
import { Default, FromSchema, type Static, Type } from "core/utils";
import { Default, FromSchema, objectToJsLiteral, type Static } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const jsonSchemaFieldConfigSchema = Type.Composite(
[
@@ -46,22 +49,16 @@ export class JsonSchemaField<
override isValid(value: any, context: TActionContext = "update"): boolean {
const parentValid = super.isValid(value, context);
//console.log("jsonSchemaField:isValid", this.getJsonSchema(), this.name, value, parentValid);
if (parentValid) {
// already checked in parent
if (!this.isRequired() && (!value || typeof value !== "object")) {
//console.log("jsonschema:valid: not checking", this.name, value, context);
return true;
}
const result = this.validator.validate(value);
//console.log("jsonschema:errors", this.name, result.errors);
return result.valid;
} else {
//console.log("jsonschema:invalid", this.name, value, context);
}
//console.log("jsonschema:invalid:fromParent", this.name, value, context);
return false;
}
@@ -89,7 +86,6 @@ export class JsonSchemaField<
try {
return Default(FromSchema(this.getJsonSchema()), {});
} catch (e) {
//console.error("jsonschema:transformRetrieve", e);
return null;
}
} else if (this.hasDefault()) {
@@ -107,13 +103,9 @@ export class JsonSchemaField<
): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value;
//console.log("jsonschema:transformPersist", this.name, _value, context);
if (!this.isValid(value)) {
//console.error("jsonschema:transformPersist:invalid", this.name, value);
throw new TransformPersistFailedException(this.name, value);
} else {
//console.log("jsonschema:transformPersist:valid", this.name, value);
}
if (!value || typeof value !== "object") return this.getDefault();
@@ -130,4 +122,12 @@ export class JsonSchemaField<
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
import: [{ package: "json-schema-to-ts", name: "FromSchema" }],
type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`,
};
}
}

View File

@@ -1,7 +1,10 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const numberFieldConfigSchema = Type.Composite(
[
@@ -100,4 +103,11 @@ export class NumberField<Required extends true | false = false> extends Field<
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "number",
};
}
}

View File

@@ -1,6 +1,9 @@
import { config } from "core";
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const primaryFieldConfigSchema = Type.Composite([
Type.Omit(baseFieldConfigSchema, ["required"]),
@@ -46,4 +49,13 @@ export class PrimaryField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
}
override toType(): TFieldTSType {
return {
...super.toType(),
required: true,
import: [{ package: "kysely", name: "Generated" }],
type: "Generated<number>",
};
}
}

View File

@@ -1,7 +1,9 @@
import { type Static, Type } from "core/utils";
import type { EntityManager } from "data";
import type { Static } from "core/utils";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export const textFieldConfigSchema = Type.Composite(
[
@@ -119,4 +121,11 @@ export class TextField<Required extends true | false = false> extends Field<
}),
);
}
override toType() {
return {
...super.toType(),
type: "string",
};
}
}

View File

@@ -1,5 +1,7 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export const virtualFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);

View File

@@ -98,12 +98,9 @@ export function fieldTestSuite(
test("toJSON", async () => {
const _config = {
..._requiredConfig,
//order: 1,
fillable: true,
required: false,
hidden: false,
//virtual: false,
//default_value: undefined
};
function fieldJson(field: Field) {
@@ -115,19 +112,16 @@ export function fieldTestSuite(
}
expect(fieldJson(noConfigField)).toEqual({
//name: "no_config",
type: noConfigField.type,
config: _config,
});
expect(fieldJson(fillable)).toEqual({
//name: "fillable",
type: noConfigField.type,
config: _config,
});
expect(fieldJson(required)).toEqual({
//name: "required",
type: required.type,
config: {
..._config,
@@ -136,7 +130,6 @@ export function fieldTestSuite(
});
expect(fieldJson(hidden)).toEqual({
//name: "hidden",
type: required.type,
config: {
..._config,
@@ -145,7 +138,6 @@ export function fieldTestSuite(
});
expect(fieldJson(dflt)).toEqual({
//name: "dflt",
type: dflt.type,
config: {
..._config,
@@ -154,7 +146,6 @@ export function fieldTestSuite(
});
expect(fieldJson(requiredAndDefault)).toEqual({
//name: "full",
type: requiredAndDefault.type,
config: {
..._config,

View File

@@ -39,7 +39,6 @@ export class EntityIndex {
return {
entity: this.entity.name,
fields: this.fields.map((f) => f.name),
//name: this.name,
unique: this.unique,
};
}

View File

@@ -18,7 +18,6 @@ export function getChangeSet(
data: EntityData,
fields: Field[],
): EntityData {
//console.log("getChangeSet", formData, data);
return transform(
formData,
(acc, _value, key) => {
@@ -32,17 +31,6 @@ export function getChangeSet(
// @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) {
acc[key] = newValue;
/*console.log("changed", {
key,
value,
valueType: typeof value,
prev: data[key],
newValue,
new: value,
sent: acc[key]
});*/
} else {
//console.log("no change", key, value, data[key]);
}
},
{} as typeof formData,

View File

@@ -1,4 +1,4 @@
import { type Static, Type, parse } from "core/utils";
import { type Static, parse } from "core/utils";
import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
import type { Entity, EntityData, EntityManager } from "../entities";
import {
@@ -8,19 +8,15 @@ import {
} from "../relations";
import type { RepoQuery } from "../server/data-query-impl";
import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number];
export type KyselyJsonFrom = any;
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
/*export type RelationConfig = {
mappedBy?: string;
inversedBy?: string;
sourceCardinality?: number;
connectionTable?: string;
connectionTableMappedName?: string;
required?: boolean;
};*/
export type BaseRelationConfig = Static<typeof EntityRelation.schema>;
// @todo: add generic type for relation config
@@ -34,7 +30,7 @@ export abstract class EntityRelation<
// @todo: add unit tests
// allowed directions, used in RelationAccessor for visibility
directions: ("source" | "target")[] = ["source", "target"];
directions: TDirection[] = ["source", "target"];
static schema = Type.Object({
mappedBy: Type.Optional(Type.String()),
@@ -109,6 +105,10 @@ export abstract class EntityRelation<
);
}
self(entity: Entity | string): EntityRelationAnchor {
return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source;
}
ref(reference: string): EntityRelationAnchor {
return this.source.reference === reference ? this.source : this.target;
}
@@ -165,7 +165,6 @@ export abstract class EntityRelation<
* @param entity
*/
isListableFor(entity: Entity): boolean {
//console.log("isListableFor", entity.name, this.source.entity.name, this.target.entity.name);
return this.target.entity.name === entity.name;
}

View File

@@ -1,12 +1,14 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import { Entity, type EntityManager } from "../entities";
import { type Field, PrimaryField, VirtualField } from "../fields";
import { type Field, PrimaryField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField } from "./RelationField";
import { type RelationType, RelationTypes } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export type ManyToManyRelationConfig = Static<typeof ManyToManyRelation.schema>;
@@ -46,7 +48,6 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
this.connectionTableMappedName = config?.connectionTableMappedName || connectionTable;
this.additionalFields = additionalFields || [];
//this.connectionTable = connectionTable;
}
static defaultConnectionTable(source: Entity, target: Entity) {

View File

@@ -1,14 +1,16 @@
import type { PrimaryFieldType } from "core";
import { snakeToPascalWithSpaces } from "core/utils";
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
import type { MutationInstructionResponse } from "./RelationMutator";
import { type RelationType, RelationTypes } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
/**
* Source entity receives the mapping field
@@ -125,7 +127,6 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
}
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
//console.log("queryInfo", entity.name, { reference, side, relationRef, entityRef, otherRef });
return {
other,

View File

@@ -1,4 +1,4 @@
import { type Static, Type } from "core/utils";
import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields";
@@ -6,6 +6,8 @@ import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export type PolymorphicRelationConfig = Static<typeof PolymorphicRelation.schema>;

View File

@@ -1,8 +1,11 @@
import { type Static, StringEnum, Type } from "core/utils";
import { type Static, StringEnum } from "core/utils";
import type { EntityManager } from "../entities";
import { Field, baseFieldConfigSchema } from "../fields";
import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
@@ -15,11 +18,6 @@ export const relationFieldConfigSchema = Type.Composite([
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
}),
]);
/*export const relationFieldConfigSchema = baseFieldConfigSchema.extend({
reference: z.string(),
target: z.string(),
target_field: z.string().catch("id"),
});*/
export type RelationFieldConfig = Static<typeof relationFieldConfigSchema>;
export type RelationFieldBaseConfig = { label?: string };
@@ -31,16 +29,6 @@ export class RelationField extends Field<RelationFieldConfig> {
return relationFieldConfigSchema;
}
/*constructor(name: string, config?: Partial<RelationFieldConfig>) {
//relation_name = relation_name || target.name;
//const name = [relation_name, target.getPrimaryField().name].join("_");
super(name, config);
//console.log(this.config);
//this.relation.target = target;
//this.relation.name = relation_name;
}*/
static create(
relation: EntityRelation,
target: EntityRelationAnchor,
@@ -50,7 +38,7 @@ export class RelationField extends Field<RelationFieldConfig> {
target.reference ?? target.entity.name,
target.entity.getPrimaryField().name,
].join("_");
//console.log('name', name);
return new RelationField(name, {
...config,
required: relation.required,
@@ -96,4 +84,11 @@ export class RelationField extends Field<RelationFieldConfig> {
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "number",
};
}
}

View File

@@ -63,7 +63,6 @@ export class RelationMutator {
// make sure it's a primitive value
// @todo: this is not a good way of checking primitives. Null is also an object
if (typeof value === "object") {
console.log("value", value);
throw new Error(`Invalid value for relation field "${key}" given, expected primitive.`);
}

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