feat: adding initial uuid support

This commit is contained in:
dswbx
2025-06-06 20:46:01 +02:00
parent abbd372ddf
commit 9a18e354cd
22 changed files with 184 additions and 52 deletions

View File

@@ -110,4 +110,18 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection); new EntityManager([entity, entity2], connection);
}).toThrow(); }).toThrow();
}); });
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
}); });

View File

@@ -70,7 +70,8 @@
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3" "swr": "^2.3.3",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
@@ -99,7 +100,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6", "jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",

View File

@@ -3,9 +3,9 @@
*/ */
import type { Generated } from "kysely"; import type { Generated } from "kysely";
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>; export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
export interface AppEntity<IdType extends number = number> { export interface AppEntity<IdType = number | string> {
id: PrimaryFieldType<IdType>; id: PrimaryFieldType<IdType>;
} }

View File

@@ -1,4 +1,10 @@
import { v4, v7 } from "uuid";
// generates v4 // generates v4
export function uuid(): string { export function uuid(): string {
return crypto.randomUUID(); return v4();
}
export function uuidv7(): string {
return v7();
} }

View File

@@ -233,6 +233,8 @@ export class DataController extends Controller {
const hono = this.create(); const hono = this.create();
const entitiesEnum = this.getEntitiesEnum(this.em); const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
/** /**
* Function endpoints * Function endpoints
@@ -333,7 +335,7 @@ export class DataController extends Controller {
"param", "param",
s.object({ s.object({
entity: entitiesEnum, entity: entitiesEnum,
id: s.string(), id: idType,
}), }),
), ),
jsc("query", repoQuery, { skipOpenAPI: true }), jsc("query", repoQuery, { skipOpenAPI: true }),
@@ -342,8 +344,9 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
console.log("id", id);
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(Number(id), options); const result = await this.em.repository(entity).findId(id, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}, },
@@ -362,7 +365,7 @@ export class DataController extends Controller {
"param", "param",
s.object({ s.object({
entity: entitiesEnum, entity: entitiesEnum,
id: s.string(), id: idType,
reference: s.string(), reference: s.string(),
}), }),
), ),
@@ -376,7 +379,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em const result = await this.em
.repository(entity) .repository(entity)
.findManyByReference(Number(id), reference, options); .findManyByReference(id, reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}, },
@@ -485,7 +488,7 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})), jsc("json", s.object({})),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.valid("param");
@@ -493,7 +496,7 @@ export class DataController extends Controller {
return this.notFound(c); return this.notFound(c);
} }
const body = (await c.req.json()) as EntityData; const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(Number(id), body); const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));
}, },
@@ -507,13 +510,13 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const result = await this.em.mutator(entity).deleteOne(Number(id)); const result = await this.em.mutator(entity).deleteOne(id);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));
}, },

View File

@@ -31,8 +31,12 @@ export class SqliteConnection extends Connection {
type, type,
(col: ColumnDefinitionBuilder) => { (col: ColumnDefinitionBuilder) => {
if (spec.primary) { if (spec.primary) {
if (spec.type === "integer") {
return col.primaryKey().notNull().autoIncrement(); return col.primaryKey().notNull().autoIncrement();
} }
return col.primaryKey().notNull();
}
if (spec.references) { if (spec.references) {
let relCol = col.references(spec.references); let relCol = col.references(spec.references);
if (spec.onDelete) relCol = relCol.onDelete(spec.onDelete); if (spec.onDelete) relCol = relCol.onDelete(spec.onDelete);

View File

@@ -1,4 +1,4 @@
import { type Static, StringRecord, objectTransform } from "core/utils"; import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
import * as tb from "@sinclair/typebox"; import * as tb from "@sinclair/typebox";
import { import {
FieldClassMap, FieldClassMap,
@@ -8,6 +8,7 @@ import {
entityTypes, entityTypes,
} from "data"; } from "data";
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
import { primaryFieldTypes } from "./fields";
export const FIELDS = { export const FIELDS = {
...FieldClassMap, ...FieldClassMap,
@@ -72,6 +73,9 @@ export const indicesSchema = tb.Type.Object(
export const dataConfigSchema = tb.Type.Object( export const dataConfigSchema = tb.Type.Object(
{ {
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })), basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
default_primary_format: tb.Type.Optional(
StringEnum(primaryFieldTypes, { default: "integer" }),
),
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })), entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })), relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })), indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),

View File

@@ -6,7 +6,13 @@ import {
snakeToPascalWithSpaces, snakeToPascalWithSpaces,
transformObject, transformObject,
} from "core/utils"; } from "core/utils";
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields"; import {
type Field,
PrimaryField,
primaryFieldTypes,
type TActionContext,
type TRenderContext,
} from "../fields";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox; const { Type } = tbbox;
@@ -18,6 +24,7 @@ export const entityConfigSchema = Type.Object(
description: Type.Optional(Type.String()), description: Type.Optional(Type.String()),
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })), sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })), sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
}, },
{ {
additionalProperties: false, additionalProperties: false,
@@ -68,7 +75,14 @@ export class Entity<
if (primary_count > 1) { if (primary_count > 1) {
throw new Error(`Entity "${name}" has more than one primary field`); throw new Error(`Entity "${name}" has more than one primary field`);
} }
this.fields = primary_count === 1 ? [] : [new PrimaryField()]; this.fields =
primary_count === 1
? []
: [
new PrimaryField(undefined, {
format: this.config.primary_format,
}),
];
if (fields) { if (fields) {
fields.forEach((field) => this.addField(field)); fields.forEach((field) => this.addField(field));

View File

@@ -143,7 +143,7 @@ export class Mutator<
// if listener returned, take what's returned // if listener returned, take what's returned
const _data = result.returned ? result.params.data : data; const _data = result.returned ? result.params.data : data;
const validatedData = { let validatedData = {
...entity.getDefaultObject(), ...entity.getDefaultObject(),
...(await this.getValidatedData(_data, "create")), ...(await this.getValidatedData(_data, "create")),
}; };
@@ -159,6 +159,16 @@ export class Mutator<
} }
} }
// primary
const primary = entity.getPrimaryField();
const primary_value = primary.getNewValue();
if (primary_value) {
validatedData = {
[primary.name]: primary_value,
...validatedData,
};
}
const query = this.conn const query = this.conn
.insertInto(entity.name) .insertInto(entity.name)
.values(validatedData) .values(validatedData)
@@ -175,7 +185,7 @@ export class Mutator<
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> { async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (!Number.isInteger(id)) { if (!id) {
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
} }
@@ -212,7 +222,7 @@ export class Mutator<
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> { async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (!Number.isInteger(id)) { if (!id) {
throw new Error("ID must be provided for deletion"); throw new Error("ID must be provided for deletion");
} }

View File

@@ -1,13 +1,17 @@
import { config } from "core"; import { config } from "core";
import type { Static } from "core/utils"; import { StringEnum, uuidv7, type Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field"; import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript"; import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox; const { Type } = tbbox;
export const primaryFieldTypes = ["integer", "uuid"] as const;
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
export const primaryFieldConfigSchema = Type.Composite([ export const primaryFieldConfigSchema = Type.Composite([
Type.Omit(baseFieldConfigSchema, ["required"]), Type.Omit(baseFieldConfigSchema, ["required"]),
Type.Object({ Type.Object({
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
required: Type.Optional(Type.Literal(false)), required: Type.Optional(Type.Literal(false)),
}), }),
]); ]);
@@ -21,8 +25,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
> { > {
override readonly type = "primary"; override readonly type = "primary";
constructor(name: string = config.data.default_primary_field) { constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
super(name, { fillable: false, required: false }); super(name, { fillable: false, required: false, ...cfg });
} }
override isRequired(): boolean { override isRequired(): boolean {
@@ -30,18 +34,34 @@ export class PrimaryField<Required extends true | false = false> extends Field<
} }
protected getSchema() { protected getSchema() {
return baseFieldConfigSchema; return primaryFieldConfigSchema;
}
get format() {
return this.config.format ?? "integer";
}
get fieldType() {
return this.format === "integer" ? "integer" : "text";
} }
override schema() { override schema() {
return Object.freeze({ return Object.freeze({
type: "integer", type: this.fieldType,
name: this.name, name: this.name,
primary: true, primary: true,
nullable: false, nullable: false,
}); });
} }
getNewValue(): any {
if (this.format === "uuid") {
return uuidv7();
}
return undefined;
}
override async transformPersist(value: any): Promise<number> { override async transformPersist(value: any): Promise<number> {
throw new Error("PrimaryField: This function should not be called"); throw new Error("PrimaryField: This function should not be called");
} }
@@ -51,11 +71,12 @@ export class PrimaryField<Required extends true | false = false> extends Field<
} }
override toType(): TFieldTSType { override toType(): TFieldTSType {
const type = this.format === "integer" ? "number" : "string";
return { return {
...super.toType(), ...super.toType(),
required: true, required: true,
import: [{ package: "kysely", name: "Generated" }], import: [{ package: "kysely", name: "Generated" }],
type: "Generated<number>", type: `Generated<${type}>`,
}; };
} }
} }

View File

@@ -9,6 +9,7 @@ import {
import type { RepoQuery } from "../server/query"; import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types"; import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
import type { PrimaryFieldType } from "core";
const { Type } = tbbox; const { Type } = tbbox;
const directions = ["source", "target"] as const; const directions = ["source", "target"] as const;
@@ -72,7 +73,7 @@ export abstract class EntityRelation<
reference: string, reference: string,
): KyselyQueryBuilder; ): KyselyQueryBuilder;
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> { getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
return {}; return {};
} }

View File

@@ -1,6 +1,6 @@
import { type Static, StringEnum } from "core/utils"; import { type Static, StringEnum } from "core/utils";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { Field, baseFieldConfigSchema } from "../fields"; import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
import type { EntityRelation } from "./EntityRelation"; import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor"; import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
@@ -15,6 +15,7 @@ export const relationFieldConfigSchema = Type.Composite([
reference: Type.String(), reference: Type.String(),
target: Type.String(), // @todo: potentially has to be an instance! target: Type.String(), // @todo: potentially has to be an instance!
target_field: Type.Optional(Type.String({ default: "id" })), target_field: Type.Optional(Type.String({ default: "id" })),
target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })),
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })), on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
}), }),
]); ]);
@@ -45,6 +46,7 @@ export class RelationField extends Field<RelationFieldConfig> {
reference: target.reference, reference: target.reference,
target: target.entity.name, target: target.entity.name,
target_field: target.entity.getPrimaryField().name, target_field: target.entity.getPrimaryField().name,
target_field_type: target.entity.getPrimaryField().fieldType,
}); });
} }
@@ -63,7 +65,7 @@ export class RelationField extends Field<RelationFieldConfig> {
override schema() { override schema() {
return Object.freeze({ return Object.freeze({
...super.schema()!, ...super.schema()!,
type: "integer", type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`, references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null", onDelete: this.config.on_delete ?? "set null",
}); });

View File

@@ -2,6 +2,7 @@ import type { CompiledQuery, TableMetadata } from "kysely";
import type { IndexMetadata, SchemaResponse } from "../connection/Connection"; import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { PrimaryField } from "../fields"; import { PrimaryField } from "../fields";
import { $console } from "core";
type IntrospectedTable = TableMetadata & { type IntrospectedTable = TableMetadata & {
indices: IndexMetadata[]; indices: IndexMetadata[];
@@ -332,6 +333,7 @@ export class SchemaManager {
if (config.force) { if (config.force) {
try { try {
$console.info("[SchemaManager]", sql, parameters);
await qb.execute(); await qb.execute();
} catch (e) { } catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`); throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);

View File

@@ -1,9 +1,10 @@
import type { Api } from "bknd/client"; import type { Api } from "bknd/client";
import type { PrimaryFieldType } from "core";
import type { RepoQueryIn } from "data"; import type { RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia"; import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema"; import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react"; import { useId, useEffect, useRef, useState } from "react";
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client"; import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone"; import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper"; import { mediaItemsToFileStates } from "./helper";
@@ -14,7 +15,7 @@ export type DropzoneContainerProps = {
infinite?: boolean; infinite?: boolean;
entity?: { entity?: {
name: string; name: string;
id: number; id: PrimaryFieldType;
field: string; field: string;
}; };
media?: Pick<TAppMediaConfig, "entity_name" | "storage">; media?: Pick<TAppMediaConfig, "entity_name" | "storage">;

View File

@@ -22,6 +22,7 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
import { bkndModals } from "ui/modals"; import { bkndModals } from "ui/modals";
import type { PrimaryFieldType } from "core";
// simplify react form types 🤦 // simplify react form types 🤦
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>; export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
@@ -30,7 +31,7 @@ export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, an
type EntityFormProps = { type EntityFormProps = {
entity: Entity; entity: Entity;
entityId?: number; entityId?: PrimaryFieldType;
data?: EntityData; data?: EntityData;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
fieldsDisabled: boolean; fieldsDisabled: boolean;
@@ -225,7 +226,7 @@ function EntityMediaFormField({
formApi: FormApi; formApi: FormApi;
field: MediaField; field: MediaField;
entity: Entity; entity: Entity;
entityId?: number; entityId?: PrimaryFieldType;
disabled?: boolean; disabled?: boolean;
}) { }) {
if (!entityId) return; if (!entityId) return;

View File

@@ -11,12 +11,14 @@ import {
type EntityFieldsFormRef, type EntityFieldsFormRef,
} from "ui/routes/data/forms/entity.fields.form"; } from "ui/routes/data/forms/entity.fields.form";
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal"; import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
const schema = entitiesSchema; const schema = entitiesSchema;
type Schema = Static<typeof schema>; type Schema = Static<typeof schema>;
export function StepEntityFields() { export function StepEntityFields() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>(); const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { config } = useBkndData();
const entity = state.entities?.create?.[0]!; const entity = state.entities?.create?.[0]!;
const defaultFields = { id: { type: "primary", name: "id" } } as const; const defaultFields = { id: { type: "primary", name: "id" } } as const;
const ref = useRef<EntityFieldsFormRef>(null); const ref = useRef<EntityFieldsFormRef>(null);
@@ -82,6 +84,8 @@ export function StepEntityFields() {
ref={ref} ref={ref}
fields={initial.fields as any} fields={initial.fields as any}
onChange={updateListener} onChange={updateListener}
defaultPrimaryFormat={config?.default_primary_format}
isNew={true}
/> />
</div> </div>
</div> </div>

View File

@@ -10,12 +10,13 @@ import {
entitySchema, entitySchema,
useStepContext, useStepContext,
} from "./CreateModal"; } from "./CreateModal";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
export function StepEntity() { export function StepEntity() {
const focusTrapRef = useFocusTrap(); const focusTrapRef = useFocusTrap();
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>(); const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { register, handleSubmit, formState, watch } = useForm({ const { register, handleSubmit, formState, watch, control } = useForm({
mode: "onTouched", mode: "onTouched",
resolver: typeboxResolver(entitySchema), resolver: typeboxResolver(entitySchema),
defaultValues: state.entities?.create?.[0] ?? {}, defaultValues: state.entities?.create?.[0] ?? {},
@@ -56,7 +57,6 @@ export function StepEntity() {
label="What's the name of the entity?" label="What's the name of the entity?"
description="Use plural form, and all lowercase. It will be used as the database table." description="Use plural form, and all lowercase. It will be used as the database table."
/> />
{/*<input type="submit" value="submit" />*/}
<TextInput <TextInput
{...register("config.name")} {...register("config.name")}
error={formState.errors.config?.name?.message} error={formState.errors.config?.name?.message}

View File

@@ -1,3 +1,4 @@
import type { PrimaryFieldType } from "core";
import { ucFirst } from "core/utils"; import { ucFirst } from "core/utils";
import type { Entity, EntityData, EntityRelation } from "data"; import type { Entity, EntityData, EntityRelation } from "data";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
@@ -24,7 +25,7 @@ export function DataEntityUpdate({ params }) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />; return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
} }
const entityId = Number.parseInt(params.id as string); const entityId = params.id as PrimaryFieldType;
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]); useBrowserTitle(["Data", entity.label, `#${entityId}`]);
@@ -202,7 +203,7 @@ function EntityDetailRelations({
entity, entity,
relations, relations,
}: { }: {
id: number; id: PrimaryFieldType;
entity: Entity; entity: Entity;
relations: EntityRelation[]; relations: EntityRelation[];
}) { }) {
@@ -250,7 +251,7 @@ function EntityDetailInner({
entity, entity,
relation, relation,
}: { }: {
id: number; id: PrimaryFieldType;
entity: Entity; entity: Entity;
relation: EntityRelation; relation: EntityRelation;
}) { }) {

View File

@@ -148,7 +148,7 @@ export function DataSchemaEntity({ params }) {
const Fields = ({ entity }: { entity: Entity }) => { const Fields = ({ entity }: { entity: Entity }) => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0); const [updates, setUpdates] = useState(0);
const { actions, $data } = useBkndData(); const { actions, $data, config } = useBkndData();
const [res, setRes] = useState<any>(); const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null); const ref = useRef<EntityFieldsFormRef>(null);
async function handleUpdate() { async function handleUpdate() {
@@ -201,6 +201,8 @@ const Fields = ({ entity }: { entity: Entity }) => {
} }
}, },
}))} }))}
defaultPrimaryFormat={config?.default_primary_format}
isNew={false}
/> />
{isDebug() && ( {isDebug() && (

View File

@@ -28,6 +28,8 @@ import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-s
import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
import { useRoutePathState } from "ui/hooks/use-route-path-state"; import { useRoutePathState } from "ui/hooks/use-route-path-state";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
const { Type } = tbbox; const { Type } = tbbox;
const fieldsSchemaObject = originalFieldsSchemaObject; const fieldsSchemaObject = originalFieldsSchemaObject;
@@ -65,6 +67,8 @@ export type EntityFieldsFormProps = {
sortable?: boolean; sortable?: boolean;
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
routePattern?: string; routePattern?: string;
defaultPrimaryFormat?: TPrimaryFieldFormat;
isNew?: boolean;
}; };
export type EntityFieldsFormRef = { export type EntityFieldsFormRef = {
@@ -77,7 +81,7 @@ export type EntityFieldsFormRef = {
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>( export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
function EntityFieldsForm( function EntityFieldsForm(
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props }, { fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
ref, ref,
) { ) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({ const entityFields = Object.entries(_fields).map(([name, field]) => ({
@@ -172,6 +176,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
remove={remove} remove={remove}
dnd={dnd} dnd={dnd}
routePattern={routePattern} routePattern={routePattern}
primary={{
defaultFormat: props.defaultPrimaryFormat,
editable: isNew,
}}
/> />
)} )}
/> />
@@ -186,6 +194,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
errors={errors} errors={errors}
remove={remove} remove={remove}
routePattern={routePattern} routePattern={routePattern}
primary={{
defaultFormat: props.defaultPrimaryFormat,
editable: isNew,
}}
/> />
))} ))}
</div> </div>
@@ -281,6 +293,7 @@ function EntityField({
errors, errors,
dnd, dnd,
routePattern, routePattern,
primary,
}: { }: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">; field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number; index: number;
@@ -292,6 +305,10 @@ function EntityField({
errors: any; errors: any;
dnd?: SortableItemProps; dnd?: SortableItemProps;
routePattern?: string; routePattern?: string;
primary?: {
defaultFormat?: TPrimaryFieldFormat;
editable?: boolean;
};
}) { }) {
const prefix = `fields.${index}.field` as const; const prefix = `fields.${index}.field` as const;
const type = field.field.type; const type = field.field.type;
@@ -363,15 +380,29 @@ function EntityField({
</div> </div>
)} )}
<div className="flex-col gap-1 hidden md:flex"> <div className="flex-col gap-1 hidden md:flex">
<span className="text-xs text-primary/50 leading-none">Required</span>
{is_primary ? ( {is_primary ? (
<Switch size="sm" defaultChecked disabled /> <>
<MantineSelect
data={["integer", "uuid"]}
defaultValue={primary?.defaultFormat}
disabled={!primary?.editable}
placeholder="Select format"
name={`${prefix}.config.format`}
allowDeselect={false}
control={control}
size="xs"
className="w-20"
/>
</>
) : ( ) : (
<>
<span className="text-xs text-primary/50 leading-none">Required</span>
<MantineSwitch <MantineSwitch
size="sm" size="sm"
name={`${prefix}.config.required`} name={`${prefix}.config.required`}
control={control} control={control}
/> />
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -30,7 +30,14 @@
"baseUrl": ".", "baseUrl": ".",
"outDir": "./dist/types", "outDir": "./dist/types",
"paths": { "paths": {
"*": ["./src/*"] "*": ["./src/*"],
"bknd": ["./src/index.ts"],
"bknd/core": ["./src/core/index.ts"],
"bknd/adapter": ["./src/adapter/index.ts"],
"bknd/client": ["./src/ui/client/index.ts"],
"bknd/data": ["./src/data/index.ts"],
"bknd/media": ["./src/media/index.ts"],
"bknd/auth": ["./src/auth/index.ts"]
} }
}, },
"include": [ "include": [

View File

@@ -27,7 +27,7 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.12.0", "version": "0.13.0",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
@@ -56,6 +56,7 @@
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3", "swr": "^2.3.3",
"uuid": "^11.1.0",
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
@@ -84,7 +85,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6", "jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
@@ -2521,7 +2522,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="], "jsonv-ts": ["jsonv-ts@0.1.0", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-wJ+79o49MNie2Xk9w1hPN8ozjqemVWXOfWUTdioLui/SeGDC7C+QKXTDxsmUaIay86lorkjb3CCGo6JDKbyTZQ=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
@@ -3603,7 +3604,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"v8-compile-cache": ["v8-compile-cache@2.4.0", "", {}, "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw=="], "v8-compile-cache": ["v8-compile-cache@2.4.0", "", {}, "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw=="],
@@ -3853,6 +3854,8 @@
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
"@cypress/request/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],