Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
},
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@types/jsonwebtoken": "^9.0.10",
"knip": "^5.83.1"
},
"workspaces": [
"packages/*"
]
],
"dependencies": {
"jsonwebtoken": "^9.0.3"
}
}
82 changes: 76 additions & 6 deletions packages/cli/src/cli/dev/dev-server/db/database.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,87 @@
import Datastore from "@seald-io/nedb";
import { nanoid } from "nanoid";
import { readAuth } from "@/core/index.js";
import type { Entity } from "@/core/resources/entity/schema.js";
import { getNowISOTimestamp } from "../utils.js";
import { type EntityRecord, Validator } from "./validator.js";

export const USER_COLLECTION = "user" as const;

export class Database {
private collections: Map<string, Datastore> = new Map();
private schemas: Map<string, Entity> = new Map();
private validator: Validator = new Validator();

load(entities: Entity[]) {
async load(entities: Entity[]) {
await this.loadUserCollection(entities);

for (const entity of entities) {
this.collections.set(entity.name, new Datastore());
this.schemas.set(entity.name, entity);
const entityName = this.normalizeName(entity.name);
if (entityName === USER_COLLECTION) {
continue;
}

this.collections.set(entityName, new Datastore());
this.schemas.set(entityName, entity);
}
}

private async loadUserCollection(entities: Entity[]) {
const userEntity = entities.find(
(e) => this.normalizeName(e.name) === USER_COLLECTION,
);

this.schemas.set(USER_COLLECTION, this.buildUserSchema(userEntity));

const collection = new Datastore();
this.collections.set(USER_COLLECTION, collection);

const userInfo = await readAuth();
const now = getNowISOTimestamp();
await collection.insertAsync({
id: nanoid(),
email: userInfo.email,
full_name: userInfo.name,
is_service: false,
is_verified: true,
disabled: null,
role: "admin",
collaborator_role: "editor",
created_date: now,
updated_date: now,
});
}

private buildUserSchema(customUserEntity: Entity | undefined): Entity {
const builtInFields = {
full_name: { type: "string" as const },
email: { type: "string" as const },
};

if (!customUserEntity) {
return {
name: "User",
type: "object",
properties: { ...builtInFields, role: { type: "string" } },
};
}

for (const field of Object.keys(builtInFields)) {
if (field in customUserEntity.properties) {
throw new Error(
`Error syncing entities: Invalid User schema: User schema cannot contain base fields: ${field}. These fields are built-in and managed by the system.`,
);
}
}

return {
...customUserEntity,
properties: { ...customUserEntity.properties, ...builtInFields },
};
}

getCollection(name: string): Datastore | undefined {
return this.collections.get(name);
return this.collections.get(this.normalizeName(name));
}

getCollectionNames(): string[] {
Expand All @@ -31,7 +97,7 @@ export class Database {
}

validate(entityName: string, record: EntityRecord, partial: boolean = false) {
const schema = this.schemas.get(entityName);
const schema = this.schemas.get(this.normalizeName(entityName));
if (!schema) {
throw new Error(`Entity "${entityName}" not found`);
}
Expand All @@ -44,7 +110,7 @@ export class Database {
record: EntityRecord,
partial: boolean = false,
) {
const schema = this.schemas.get(entityName);
const schema = this.schemas.get(this.normalizeName(entityName));
if (!schema) {
throw new Error(`Entity "${entityName}" not found`);
}
Expand All @@ -55,4 +121,8 @@ export class Database {
}
return this.validator.applyDefaults(filteredRecord, schema);
}

private normalizeName(entityName: string): string {
return entityName.toLowerCase();
}
Comment on lines +125 to +127
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behind the scenes production is treating entity name as case-insensitive: "User" and "user" are the same.

}
21 changes: 12 additions & 9 deletions packages/cli/src/cli/dev/dev-server/db/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import type {

export type EntityRecord = Record<string, unknown>;

type ValidationError = {
type ValidationErrorContext = {
error_type: "ValidationError";
message: string;
request_id: null;
traceback: "";
};

export class EntityValidationError extends Error {
constructor(public context: ValidationErrorContext) {
super(context.message);
}
}

type ValidationResponse =
| {
hasError: false;
}
| {
hasError: true;
error: ValidationError;
error: ValidationErrorContext;
};

// https://docs.base44.com/developers/backend/resources/entities/entity-schemas#field-types
Expand Down Expand Up @@ -65,7 +71,7 @@ export class Validator {
record: EntityRecord,
entitySchema: Entity,
partial: boolean = false,
): ValidationResponse {
) {
// Partial validation happening, when user updates existing entity.
// In this case not all data will be passed.
if (!partial) {
Expand All @@ -74,19 +80,16 @@ export class Validator {
entitySchema,
);
if (requiredFieldsResponse.hasError) {
return requiredFieldsResponse;
throw new EntityValidationError(requiredFieldsResponse.error);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that it's more convenient to throw the error, rather than return an object that describes the error.
So I decided to refactor it a bit.

}
}
const fieldTypesResponse = this.validateFieldTypes(record, entitySchema);
if (fieldTypesResponse.hasError) {
return fieldTypesResponse;
throw new EntityValidationError(fieldTypesResponse.error);
}
return {
hasError: false,
};
}

private createValidationError(message: string): ValidationError {
private createValidationError(message: string): ValidationErrorContext {
return {
error_type: "ValidationError",
message,
Expand Down
13 changes: 5 additions & 8 deletions packages/cli/src/cli/dev/dev-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
broadcastEntityEvent,
createRealtimeServer,
} from "./realtime.js";
import { createEntityRoutes } from "./routes/entities.js";
import { createEntityRoutes } from "./routes/entities/entities-router.js";
import {
createCustomIntegrationRoutes,
createFileToken,
Expand Down Expand Up @@ -93,19 +93,16 @@ export async function createDevServer(
}

const db = new Database();
db.load(entities);
await db.load(entities);
if (db.getCollectionNames().length > 0) {
clackLog.info(`Loaded entities: ${db.getCollectionNames().join(", ")}`);
}

// Socket.IO is attached after the HTTP server starts; entity routes receive
// a broadcast callback that becomes a no-op until the server is ready.
let emitEntityEvent: BroadcastEntityEvent = () => {};
const entityRoutes = createEntityRoutes(
db,
devLogger,
remoteProxy,
(...args) => emitEntityEvent(...args),
const entityRoutes = await createEntityRoutes(db, devLogger, (...args) =>
emitEntityEvent(...args),
);
app.use("/api/apps/:appId/entities", entityRoutes);

Expand Down Expand Up @@ -205,7 +202,7 @@ export async function createDevServer(
if (previousEntityCount > 0) {
devLogger.log("Entities directory changed, clearing data...");
}
db.load(entities);
await db.load(entities);
if (db.getCollectionNames().length > 0) {
devLogger.log(
`Loaded entities: ${db.getCollectionNames().join(", ")}`,
Expand Down
Loading
Loading