Merge branch 'release/0.8' into refactor/data-api-entity-prefix

This commit is contained in:
dswbx
2025-02-18 10:22:15 +01:00
committed by GitHub
20 changed files with 166 additions and 78 deletions

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.8.0-rc.5", "version": "0.8.0-rc.6",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {

View File

@@ -26,6 +26,10 @@ export class AppFirstBoot extends AppEvent {
} }
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type AppOptions = {
plugins?: AppPlugin[];
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
};
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
| Connection | Connection
@@ -36,8 +40,7 @@ export type CreateAppConfig = {
} }
| LibSqlCredentials; | LibSqlCredentials;
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin[]; options?: AppOptions;
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
@@ -47,32 +50,33 @@ export class App {
static readonly Events = AppEvents; static readonly Events = AppEvents;
adminController?: AdminController; adminController?: AdminController;
private trigger_first_boot = false; private trigger_first_boot = false;
private plugins: AppPlugin[];
constructor( constructor(
private connection: Connection, private connection: Connection,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin[] = [], private options?: AppOptions
moduleManagerOptions?: ModuleManagerOptions
) { ) {
this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...moduleManagerOptions, ...(options?.manager ?? {}),
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { onUpdated: async (key, config) => {
// if the EventManager was disabled, we assume we shouldn't // if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated". // respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually // this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) { if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip."); console.warn("App config updated, but event manager is disabled, skip.");
return; return;
} }
console.log("[APP] config updated", key); console.log("App config updated", key);
// @todo: potentially double syncing // @todo: potentially double syncing
await this.build({ sync: true }); await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}, },
onFirstBoot: async () => { onFirstBoot: async () => {
console.log("[APP] first boot"); console.log("App first boot");
this.trigger_first_boot = true; this.trigger_first_boot = true;
}, },
onServerInit: async (server) => { onServerInit: async (server) => {
@@ -106,8 +110,6 @@ export class App {
await this.emgr.emit(new AppBuiltEvent({ app: this })); await this.emgr.emit(new AppBuiltEvent({ app: this }));
server.all("/api/*", async (c) => c.notFound());
// first boot is set from ModuleManager when there wasn't a config table // first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) { if (this.trigger_first_boot) {
this.trigger_first_boot = false; this.trigger_first_boot = false;
@@ -183,7 +185,7 @@ export function createApp(config: CreateAppConfig = {}) {
} else if (typeof config.connection === "object") { } else if (typeof config.connection === "object") {
if ("type" in config.connection) { if ("type" in config.connection) {
console.warn( console.warn(
"[WARN] Using deprecated connection type 'libsql', use the 'config' object directly." "Using deprecated connection type 'libsql', use the 'config' object directly."
); );
connection = new LibsqlConnection(config.connection.config); connection = new LibsqlConnection(config.connection.config);
} else { } else {
@@ -191,7 +193,7 @@ export function createApp(config: CreateAppConfig = {}) {
} }
} else { } else {
connection = new LibsqlConnection({ url: ":memory:" }); connection = new LibsqlConnection({ url: ":memory:" });
console.warn("[!] No connection provided, using in-memory database"); console.warn("No connection provided, using in-memory database");
} }
} catch (e) { } catch (e) {
console.error("Could not create connection", e); console.error("Could not create connection", e);
@@ -201,5 +203,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
} }
return new App(connection, config.initialConfig, config.plugins, config.options); return new App(connection, config.initialConfig, config.options);
} }

View File

@@ -30,7 +30,6 @@ export function serve({
distPath, distPath,
connection, connection,
initialConfig, initialConfig,
plugins,
options, options,
port = config.server.default_port, port = config.server.default_port,
onBuilt, onBuilt,
@@ -44,7 +43,6 @@ export function serve({
const app = await createApp({ const app = await createApp({
connection, connection,
initialConfig, initialConfig,
plugins,
options, options,
onBuilt, onBuilt,
buildConfig, buildConfig,

View File

@@ -1,20 +1,45 @@
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import { createApp } from "App";
import { getConnectionCredentialsFromEnv } from "cli/commands/run/platform";
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys"; import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
import { Argument } from "commander";
import { showRoutes } from "hono/dev";
import type { CliCommand } from "../types"; import type { CliCommand } from "../types";
export const debug: CliCommand = (program) => { export const debug: CliCommand = (program) => {
program program
.command("debug") .command("debug")
.description("debug path resolution") .description("debug bknd")
.action(() => { .addArgument(new Argument("<subject>", "subject to debug").choices(Object.keys(subjects)))
console.log("paths", { .action(action);
rootpath: getRootPath(),
distPath: getDistPath(),
relativeDistPath: getRelativeDistPath(),
cwd: process.cwd(),
dir: path.dirname(url.fileURLToPath(import.meta.url)),
resolvedPkg: path.resolve(getRootPath(), "package.json")
});
});
}; };
const subjects = {
paths: async () => {
console.log("[PATHS]", {
rootpath: getRootPath(),
distPath: getDistPath(),
relativeDistPath: getRelativeDistPath(),
cwd: process.cwd(),
dir: path.dirname(url.fileURLToPath(import.meta.url)),
resolvedPkg: path.resolve(getRootPath(), "package.json")
});
},
routes: async () => {
console.log("[APP ROUTES]");
const credentials = getConnectionCredentialsFromEnv();
const app = createApp({ connection: credentials });
await app.build();
showRoutes(app.server);
}
};
async function action(subject: string) {
console.log("debug", { subject });
if (subject in subjects) {
await subjects[subject]();
} else {
console.error("Invalid subject: ", subject);
}
}

View File

@@ -32,7 +32,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer(server: Platform, app: any, options: { port: number }) { export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port; const port = options.port;
console.log(`(using ${server} serve)`); console.log(`Using ${server} serve`);
switch (server) { switch (server) {
case "node": { case "node": {
@@ -54,7 +54,7 @@ export async function startServer(server: Platform, app: any, options: { port: n
} }
const url = `http://localhost:${port}`; const url = `http://localhost:${port}`;
console.log(`Server listening on ${url}`); console.info("Server listening on", url);
await open(url); await open(url);
} }

View File

@@ -2,10 +2,12 @@ import type { Config } from "@libsql/client/node";
import { App, type CreateAppConfig } from "App"; import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node"; import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { replaceConsole } from "cli/utils/cli";
import { Option } from "commander"; import { Option } from "commander";
import { config } from "core"; import { config } from "core";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import c from "picocolors";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
@@ -27,6 +29,13 @@ export const run: CliCommand = (program) => {
.default(config.server.default_port) .default(config.server.default_port)
.argParser((v) => Number.parseInt(v)) .argParser((v) => Number.parseInt(v))
) )
.addOption(
new Option("-m, --memory", "use in-memory database").conflicts([
"config",
"db-url",
"db-token"
])
)
.addOption(new Option("-c, --config <config>", "config file")) .addOption(new Option("-c, --config <config>", "config file"))
.addOption( .addOption(
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts( new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
@@ -97,33 +106,44 @@ export async function makeConfigApp(config: CliBkndConfig, platform?: Platform)
async function action(options: { async function action(options: {
port: number; port: number;
memory?: boolean;
config?: string; config?: string;
dbUrl?: string; dbUrl?: string;
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
}) { }) {
replaceConsole();
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
let app: App | undefined = undefined; let app: App | undefined = undefined;
if (options.dbUrl) { if (options.dbUrl) {
console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl const connection = options.dbUrl
? { url: options.dbUrl, authToken: options.dbToken } ? { url: options.dbUrl, authToken: options.dbToken }
: undefined; : undefined;
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
} else if (configFilePath) { } else if (configFilePath) {
console.log("[INFO] Using config from:", configFilePath); console.info("Using config from", c.cyan(configFilePath));
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server); app = await makeConfigApp(config, options.server);
} else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection");
app = await makeApp({ server: { platform: options.server } });
} else { } else {
const credentials = getConnectionCredentialsFromEnv(); const credentials = getConnectionCredentialsFromEnv();
if (credentials) { if (credentials) {
console.log("[INFO] Using connection from environment"); console.info("Using connection from env", c.cyan(credentials.url));
app = await makeConfigApp({ app: { connection: credentials } }, options.server); app = await makeConfigApp({ app: { connection: credentials } }, options.server);
} }
} }
if (!app) { if (!app) {
app = await makeApp({ server: { platform: options.server } }); const connection = { url: "file:data.db" } as Config;
console.info("Using connection", c.cyan(connection.url));
app = await makeApp({
connection,
server: { platform: options.server }
});
} }
await startServer(options.server, app, { port: options.port }); await startServer(options.server, app, { port: options.port });

View File

@@ -1,3 +1,6 @@
import { isDebug } from "core";
import c from "picocolors";
import type { Formatter } from "picocolors/types";
const _SPEEDUP = process.env.LOCAL; const _SPEEDUP = process.env.LOCAL;
const DEFAULT_WAIT = _SPEEDUP ? 0 : 250; const DEFAULT_WAIT = _SPEEDUP ? 0 : 250;
@@ -54,3 +57,31 @@ export async function* typewriter(
} }
} }
} }
function ifString(args: any[], c: Formatter) {
return args.map((a) => (typeof a === "string" ? c(a) : a));
}
const originalConsole = {
log: console.log,
info: console.info,
debug: console.debug,
warn: console.warn,
error: console.error
};
export const $console = {
log: (...args: any[]) => originalConsole.info(c.gray("[LOG] "), ...ifString(args, c.dim)),
info: (...args: any[]) => originalConsole.info(c.cyan("[INFO] "), ...args),
debug: (...args: any[]) => isDebug() && originalConsole.info(c.yellow("[DEBUG]"), ...args),
warn: (...args: any[]) => originalConsole.info(c.yellow("[WARN] "), ...ifString(args, c.yellow)),
error: (...args: any[]) => originalConsole.info(c.red("[ERROR]"), ...ifString(args, c.red))
};
export function replaceConsole() {
console.log = $console.log;
console.info = $console.info;
console.debug = $console.debug;
console.warn = $console.warn;
console.error = $console.error;
}

View File

@@ -144,7 +144,7 @@ export class DataController extends Controller {
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity, context } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.warn("not found:", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
@@ -228,7 +228,7 @@ export class DataController extends Controller {
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.warn("not found:", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;

View File

@@ -63,6 +63,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
}); });
}); });
hono.all("*", (c) => c.notFound());
this.ctx.server.route(this.config.basepath, hono); this.ctx.server.route(this.config.basepath, hono);
// register flows // register flows

View File

@@ -7,6 +7,7 @@ import { html } from "hono/html";
import { Fragment } from "hono/jsx"; import { Fragment } from "hono/jsx";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppTheme } from "modules/server/AppServer";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -246,7 +247,7 @@ export class AdminController extends Controller {
} }
} }
const style = (theme: "light" | "dark" = "light") => { const style = (theme: AppTheme) => {
const base = { const base = {
margin: 0, margin: 0,
padding: 0, padding: 0,
@@ -271,6 +272,6 @@ const style = (theme: "light" | "dark" = "light") => {
return { return {
...base, ...base,
...styles[theme] ...styles[theme === "light" ? "light" : "dark"]
}; };
}; };

View File

@@ -4,12 +4,15 @@ import { cors } from "hono/cors";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
const appThemes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof appThemes)[number];
export const serverConfigSchema = Type.Object( export const serverConfigSchema = Type.Object(
{ {
admin: Type.Object( admin: Type.Object(
{ {
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })), basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
color_scheme: Type.Optional(StringEnum(["dark", "light"], { default: "light" })), color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
logo_return_path: Type.Optional( logo_return_path: Type.Optional(
Type.String({ Type.String({
default: "/", default: "/",

View File

@@ -44,7 +44,7 @@ function AdminInternal() {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<MantineProvider {...createMantineTheme(theme ?? "light")}> <MantineProvider {...createMantineTheme(theme as any)}>
<Notifications /> <Notifications />
<FlashMessage /> <FlashMessage />
<BkndModalsProvider> <BkndModalsProvider>

View File

@@ -1,5 +1,6 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core"; import { isDebug } from "core";
import type { AppTheme } from "modules/server/AppServer";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
@@ -61,7 +62,7 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: "light" | "dark"; color_scheme?: AppTheme;
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {

View File

@@ -1,18 +1,26 @@
import type { AppTheme } from "modules/server/AppServer";
import { useBkndWindowContext } from "ui/client/ClientProvider"; import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export type Theme = "light" | "dark"; export function useTheme(fallback: AppTheme = "system"): { theme: AppTheme } {
export function useTheme(fallback: Theme = "light"): { theme: Theme } {
const b = useBknd(); const b = useBknd();
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
if (b) {
if (b?.adminOverride?.color_scheme) { // 1. override
return { theme: b.adminOverride.color_scheme }; // 2. config
} else if (!b.fallback) { // 3. winCtx
return { theme: b.config.server.admin.color_scheme ?? fallback }; // 4. fallback
} // 5. default
const override = b?.adminOverride?.color_scheme;
const config = b?.config.server.admin.color_scheme;
const win = winCtx.color_scheme;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = override ?? config ?? win ?? fallback;
if (theme === "system") {
return { theme: prefersDark ? "dark" : "light" };
} }
return { theme: winCtx.color_scheme ?? fallback }; return { theme };
} }

View File

@@ -10,12 +10,6 @@ import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root"; import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings"; import SettingsRoutes from "./settings";
/*const DataRoutes = lazy(() => import("./data"));
const AuthRoutes = lazy(() => import("./auth"));
const MediaRoutes = lazy(() => import("./media"));
const FlowRoutes = lazy(() => import("./flows"));
const SettingsRoutes = lazy(() => import("./settings"));*/
// @ts-ignore // @ts-ignore
const TestRoutes = lazy(() => import("./test")); const TestRoutes = lazy(() => import("./test"));

View File

@@ -1,3 +1,5 @@
import tailwindCssAnimate from "tailwindcss-animate";
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
content: ["./index.html", "./src/ui/**/*.tsx", "./src/ui/lib/mantine/theme.ts"], content: ["./index.html", "./src/ui/**/*.tsx", "./src/ui/lib/mantine/theme.ts"],
@@ -13,5 +15,5 @@ export default {
} }
} }
}, },
plugins: [require("tailwindcss-animate")] plugins: [tailwindCssAnimate]
}; };

View File

@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev";
import { App, registries } from "./src"; import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
@@ -28,6 +29,7 @@ if (example) {
let app: App; let app: App;
const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1"; const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1";
let routesShown = false;
export default { export default {
async fetch(request: Request) { async fetch(request: Request) {
if (!app || recreate) { if (!app || recreate) {
@@ -44,6 +46,14 @@ export default {
"sync" "sync"
); );
await app.build(); await app.build();
// log routes
if (!routesShown) {
routesShown = true;
console.log("\n\n[APP ROUTES]");
showRoutes(app.server);
console.log("-------\n\n");
}
} }
return app.fetch(request); return app.fetch(request);

BIN
bun.lockb

Binary file not shown.

View File

@@ -17,10 +17,7 @@ The easiest to get started is using SQLite in-memory. When serving the API in th
the function accepts an object with connection details. To use an in-memory database, you can either omit the object completely or explicitly use it as follows: the function accepts an object with connection details. To use an in-memory database, you can either omit the object completely or explicitly use it as follows:
```json ```json
{ {
"type": "libsql", "url": ":memory:"
"config": {
"url": ":memory:"
}
} }
``` ```
@@ -29,10 +26,7 @@ Just like the in-memory option, using a file is just as easy:
```json ```json
{ {
"type": "libsql", "url": "file:<path/to/your/database.db>"
"config": {
"url": "file:<path/to/your/database.db>"
}
} }
``` ```
Please note that using SQLite as a file is only supported in server environments. Please note that using SQLite as a file is only supported in server environments.
@@ -47,10 +41,7 @@ turso dev
The command will yield a URL. Use it in the connection object: The command will yield a URL. Use it in the connection object:
```json ```json
{ {
"type": "libsql", "url": "http://localhost:8080"
"config": {
"url": "http://localhost:8080"
}
} }
``` ```
@@ -59,11 +50,8 @@ If you want to use LibSQL on Turso, [sign up for a free account](https://turso.t
connection object to your new database: connection object to your new database:
```json ```json
{ {
"type": "libsql", "url": "libsql://your-database-url.turso.io",
"config": { "authToken": "your-auth-token"
"url": "libsql://your-database-url.turso.io",
"authToken": "your-auth-token"
}
} }
``` ```

View File

@@ -44,18 +44,21 @@ import type { Connection } from "bknd/data";
import type { Config } from "@libsql/client"; import type { Config } from "@libsql/client";
type AppPlugin = (app: App) => Promise<void> | void; type AppPlugin = (app: App) => Promise<void> | void;
type ManagerOptions = {
basePath?: string;
trustFetched?: boolean;
onFirstBoot?: () => Promise<void>;
seed?: (ctx: ModuleBuildContext) => Promise<void>;
};
type CreateAppConfig = { type CreateAppConfig = {
connection?: connection?:
| Connection | Connection
| Config; | Config;
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin[];
options?: { options?: {
basePath?: string; plugins?: AppPlugin[];
trustFetched?: boolean; manager?: ManagerOptions
onFirstBoot?: () => Promise<void>;
seed?: (ctx: ModuleBuildContext) => Promise<void>;
}; };
}; };
``` ```