@njinlabs/njin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/package.json +59 -0
  4. package/src/cli/build.ts +162 -0
  5. package/src/cli/dev.ts +11 -0
  6. package/src/cli/index.ts +21 -0
  7. package/src/cli/start.ts +9 -0
  8. package/src/config/module.ts +34 -0
  9. package/src/core/adapters/bun_filesystem.ts +37 -0
  10. package/src/core/adapters/s3.ts +60 -0
  11. package/src/core/banner.ts +54 -0
  12. package/src/core/boot.ts +10 -0
  13. package/src/core/config.ts +89 -0
  14. package/src/core/http_error.ts +21 -0
  15. package/src/core/model/data_type/array.ts +22 -0
  16. package/src/core/model/data_type/boolean.ts +15 -0
  17. package/src/core/model/data_type/date.ts +28 -0
  18. package/src/core/model/data_type/email.ts +21 -0
  19. package/src/core/model/data_type/file.ts +15 -0
  20. package/src/core/model/data_type/index.ts +13 -0
  21. package/src/core/model/data_type/multi_file.ts +18 -0
  22. package/src/core/model/data_type/numeric.ts +15 -0
  23. package/src/core/model/data_type/object.ts +15 -0
  24. package/src/core/model/data_type/relation.ts +73 -0
  25. package/src/core/model/data_type/relation_many.ts +55 -0
  26. package/src/core/model/data_type/richtext.ts +20 -0
  27. package/src/core/model/data_type/select.ts +15 -0
  28. package/src/core/model/data_type/text.ts +20 -0
  29. package/src/core/model/index.ts +244 -0
  30. package/src/core/module/index.ts +12 -0
  31. package/src/core/path_guard.ts +9 -0
  32. package/src/models/file.ts +17 -0
  33. package/src/models/user.ts +18 -0
  34. package/src/modules/admin.ts +43 -0
  35. package/src/modules/analytics.ts +212 -0
  36. package/src/modules/api.ts +160 -0
  37. package/src/modules/auth.ts +153 -0
  38. package/src/modules/elysia.ts +85 -0
  39. package/src/modules/file.ts +131 -0
  40. package/src/modules/img.ts +137 -0
  41. package/src/modules/logger.ts +18 -0
  42. package/src/modules/setup.ts +89 -0
  43. package/src/modules/surreal.ts +58 -0
  44. package/src/modules/users.ts +94 -0
  45. package/src/modules/view.ts +247 -0
  46. package/src/spin.ts +3 -0
@@ -0,0 +1,54 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfig } from "./config";
4
+
5
+ const c = {
6
+ reset: "\x1b[0m",
7
+ bold: "\x1b[1m",
8
+ dim: "\x1b[2m",
9
+ cyan: "\x1b[36m",
10
+ green: "\x1b[32m",
11
+ yellow: "\x1b[33m",
12
+ };
13
+
14
+ const PKG_VERSION: string = (() => {
15
+ try {
16
+ // Safe to use import.meta.dir here — this looks up njin's OWN version,
17
+ // not anything project-specific (unlike view.ts's views lookup).
18
+ return (require(join(import.meta.dir, "../../package.json")) as { version: string }).version;
19
+ } catch {
20
+ return "0.0.0";
21
+ }
22
+ })();
23
+
24
+ export type BootSummary = {
25
+ mode: "development" | "production";
26
+ };
27
+
28
+ // Printed once boot finishes — plain console.log (not pino) on purpose: this is a
29
+ // human-facing startup summary, not a structured log line.
30
+ export const printBanner = ({ mode }: BootSummary): void => {
31
+ const config = getConfig();
32
+ const port = config.port;
33
+ const adminEntry = join(process.cwd(), "_admin", "index.html");
34
+ const hasAdmin = existsSync(adminEntry);
35
+
36
+ const rows: [string, string][] = [
37
+ ["Local", `http://localhost:${port}`],
38
+ ["Database", `${config.db.path} ${c.dim}(${config.db.namespace}/${config.db.database})${c.reset}`],
39
+ ["Models", `${config.models.length} registered`],
40
+ hasAdmin ? ["Admin", `http://localhost:${port}/_admin`] : ["Admin", `${c.dim}not found — drop a built admin panel into /_admin${c.reset}`],
41
+ ];
42
+
43
+ const label = (text: string) => `${c.dim}${text.padEnd(9)}${c.reset}`;
44
+
45
+ const lines = [
46
+ "",
47
+ ` ${c.bold}${c.cyan}njin${c.reset} ${c.dim}v${PKG_VERSION}${c.reset} ${mode === "production" ? c.green : c.yellow}${mode}${c.reset}`,
48
+ "",
49
+ ...rows.map(([key, value]) => ` ${c.green}➜${c.reset} ${label(key)} ${value}`),
50
+ "",
51
+ ];
52
+
53
+ console.log(lines.join("\n"));
54
+ };
@@ -0,0 +1,10 @@
1
+ import modules from "../config/module";
2
+ import type { ModuleReturn } from "./module";
3
+
4
+ // Runs every module's spin() in the order config/module.ts defines — boot.ts is the
5
+ // shared implementation behind `njin dev`/`njin start` (and the legacy src/spin.ts entry).
6
+ export const boot = async (): Promise<void> => {
7
+ for (const module of modules) {
8
+ if ((module as ModuleReturn).spin) await (module as ModuleReturn).spin!();
9
+ }
10
+ };
@@ -0,0 +1,89 @@
1
+ import type { FileAdapter } from "../modules/file";
2
+ import { join } from "node:path";
3
+ import bunFilesystemAdapter from "./adapters/bun_filesystem";
4
+ import type { makeModel } from "./model";
5
+
6
+ export type ModelFactory = () => Promise<{ default: ReturnType<typeof makeModel> }>;
7
+
8
+ export type NjinConfig = {
9
+ port?: number;
10
+ db?: {
11
+ path?: string;
12
+ namespace?: string;
13
+ database?: string;
14
+ };
15
+ img?: {
16
+ hosts?: string[];
17
+ };
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ adapters?: {
20
+ file?: FileAdapter<any>;
21
+ };
22
+ models?: ModelFactory[];
23
+ };
24
+
25
+ export type ResolvedConfig = {
26
+ port: number;
27
+ db: { path: string; namespace: string; database: string };
28
+ img: { hosts: string[] };
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ adapters: { file: FileAdapter<any> };
31
+ models: ModelFactory[];
32
+ };
33
+
34
+ // Identity function — purely for DX (autocomplete/type-checking on the config
35
+ // object literal), same purpose as Vite's own `defineConfig`.
36
+ export const defineConfig = (config: NjinConfig): NjinConfig => config;
37
+
38
+ let resolved: ResolvedConfig | null = null;
39
+
40
+ // Reads `config.ts` from the consuming project's root (process.cwd()) — never
41
+ // from this package's own location, since that would break once this module
42
+ // lives inside node_modules/njin instead of the project itself.
43
+ //
44
+ // `preloaded` lets `njin build` skip the dynamic file read entirely: its generated
45
+ // entry imports the project's config.ts with a static, literal import, which
46
+ // `bun build --compile` can trace and embed directly into the binary. A dynamic
47
+ // import() of a runtime-computed path can't be bundled that way.
48
+ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
49
+ // Idempotent unless explicitly given a preloaded config: module.ts's own no-arg call is
50
+ // just a safety net ("ensure config is loaded before any module reads getConfig()") for
51
+ // njin dev/start, where nothing else loads it first. In a compiled binary, the generated
52
+ // entry calls loadConfig(config) itself with the real config — without this guard, module.ts's
53
+ // no-arg call (reached as a side effect of importing it) would otherwise silently clobber that
54
+ // back to empty defaults.
55
+ if (!preloaded && resolved) return;
56
+
57
+ let userConfig: NjinConfig;
58
+
59
+ if (preloaded) {
60
+ userConfig = preloaded;
61
+ } else {
62
+ const path = join(process.cwd(), "config.ts");
63
+
64
+ userConfig = {};
65
+ try {
66
+ const mod = await import(path);
67
+ userConfig = mod.default ?? {};
68
+ } catch {
69
+ // No config.ts at the project root — run on defaults only.
70
+ }
71
+ }
72
+
73
+ resolved = {
74
+ port: userConfig.port ?? 3000,
75
+ db: {
76
+ path: userConfig.db?.path ?? "rocksdb://data",
77
+ namespace: userConfig.db?.namespace ?? "general",
78
+ database: userConfig.db?.database ?? "general",
79
+ },
80
+ img: { hosts: userConfig.img?.hosts ?? [] },
81
+ adapters: { file: userConfig.adapters?.file ?? bunFilesystemAdapter() },
82
+ models: userConfig.models ?? [],
83
+ };
84
+ };
85
+
86
+ export const getConfig = (): ResolvedConfig => {
87
+ if (!resolved) throw new Error("Config not loaded yet — loadConfig() must run before getConfig() is called.");
88
+ return resolved;
89
+ };
@@ -0,0 +1,21 @@
1
+ export class HttpError extends Error {
2
+ constructor(
3
+ public readonly statusCode: number,
4
+ message?: string,
5
+ ) {
6
+ super(message ?? httpStatusMessage(statusCode));
7
+ this.name = "HttpError";
8
+ }
9
+ }
10
+
11
+ function httpStatusMessage(code: number): string {
12
+ const messages: Record<number, string> = {
13
+ 400: "Bad Request",
14
+ 401: "Unauthorized",
15
+ 403: "Forbidden",
16
+ 404: "Not Found",
17
+ 422: "Unprocessable Entity",
18
+ 500: "Internal Server Error",
19
+ };
20
+ return messages[code] ?? `HTTP Error ${code}`;
21
+ }
@@ -0,0 +1,22 @@
1
+ import z from "zod";
2
+ import { type FormMeta } from "..";
3
+
4
+ export function array<T extends z.ZodTypeAny>(meta: FormMeta, input: T): z.ZodPreprocess<z.ZodArray<T>>;
5
+
6
+ export function array<T extends z.ZodTypeAny, Z extends z.ZodTypeAny>(meta: FormMeta, input: T, rule: (z: z.ZodArray<T>) => Z): z.ZodPreprocess<Z>;
7
+
8
+ export function array(meta: FormMeta, input: z.ZodTypeAny, rule?: (z: any) => any) {
9
+ const baseRule = z.preprocess(
10
+ (value) => {
11
+ if (!!value && !Array.isArray(value)) return [value];
12
+
13
+ return value;
14
+ },
15
+ rule ? rule(z.array(input)) : z.array(input),
16
+ );
17
+
18
+ return baseRule.meta({
19
+ ...meta,
20
+ renderAs: "array",
21
+ });
22
+ }
@@ -0,0 +1,15 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function boolean(meta: FormMeta): z.ZodBoolean;
5
+
6
+ export function boolean<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodBoolean) => T): T;
7
+
8
+ export function boolean(meta: FormMeta, rule?: (z: any) => any) {
9
+ const baseRule = z.boolean();
10
+
11
+ return (rule ? rule(baseRule) : baseRule).meta({
12
+ ...meta,
13
+ renderAs: "boolean",
14
+ });
15
+ }
@@ -0,0 +1,28 @@
1
+ import moment from "moment";
2
+ import z from "zod";
3
+ import type { FormMeta } from "..";
4
+
5
+ export function date(meta: FormMeta): z.ZodPreprocess<z.ZodString>;
6
+
7
+ export function date<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodString) => T): z.ZodPreprocess<T>;
8
+
9
+ export function date(meta: FormMeta, rule?: (z: any) => any) {
10
+ const inner = rule ? rule(z.string()) : z.string();
11
+
12
+ return z
13
+ .preprocess((value) => {
14
+ if (moment.isMoment(value)) {
15
+ return value.toISOString();
16
+ }
17
+
18
+ if (typeof value === "string") {
19
+ return moment(value).toISOString();
20
+ }
21
+
22
+ return undefined;
23
+ }, inner)
24
+ .meta({
25
+ ...meta,
26
+ renderAs: "datepicker",
27
+ });
28
+ }
@@ -0,0 +1,21 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function email(meta: FormMeta): z.ZodPreprocess<z.ZodEmail>;
5
+
6
+ export function email<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodEmail) => T): z.ZodPreprocess<T>;
7
+
8
+ export function email(meta: FormMeta, rule?: (z: any) => any) {
9
+ const inner = rule ? rule(z.email()) : z.email();
10
+
11
+ return z
12
+ .preprocess((value) => {
13
+ if (!value) return undefined;
14
+
15
+ return value;
16
+ }, inner)
17
+ .meta({
18
+ ...meta,
19
+ renderAs: "text",
20
+ });
21
+ }
@@ -0,0 +1,15 @@
1
+ import fileModule from "../../../modules/file";
2
+ import type z from "zod";
3
+ import { relation, type FormMeta, type RelationType } from "..";
4
+
5
+ export function file(meta: FormMeta): RelationType<ReturnType<ReturnType<typeof fileModule>["model"]["validation"]["partial"]>>;
6
+
7
+ export function file<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: RelationType<ReturnType<ReturnType<typeof fileModule>["model"]["validation"]["partial"]>>) => T): T;
8
+
9
+ export function file(meta: FormMeta, rule?: (z: any) => any) {
10
+ return relation(meta, fileModule().model, rule ? rule : (z) => z).meta({
11
+ ...meta,
12
+ renderAs: "file",
13
+ model: fileModule().model.prefix,
14
+ });
15
+ }
@@ -0,0 +1,13 @@
1
+ export * from "./array";
2
+ export * from "./boolean";
3
+ export * from "./date";
4
+ export * from "./email";
5
+ export * from "./file";
6
+ export * from "./multi_file";
7
+ export * from "./numeric";
8
+ export * from "./object";
9
+ export * from "./relation";
10
+ export * from "./relation_many";
11
+ export * from "./richtext";
12
+ export * from "./select";
13
+ export * from "./text";
@@ -0,0 +1,18 @@
1
+ import fileModule from "../../../modules/file";
2
+ import type z from "zod";
3
+ import { type FormMeta, type RelationType } from "..";
4
+ import { relationMany } from "./relation_many";
5
+
6
+ type Return = RelationType<ReturnType<ReturnType<typeof fileModule>["model"]["validation"]["partial"]>>;
7
+
8
+ export function multiFile(meta: FormMeta): z.ZodPreprocess<z.ZodArray<Return>>;
9
+
10
+ export function multiFile<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodArray<Return>) => T): T;
11
+
12
+ export function multiFile(meta: FormMeta, rule?: (z: any) => any) {
13
+ return relationMany(meta, fileModule().model, rule ? rule : (z) => z).meta({
14
+ ...meta,
15
+ renderAs: "multi_file",
16
+ model: fileModule().model.prefix,
17
+ });
18
+ }
@@ -0,0 +1,15 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function numeric(meta: FormMeta): z.ZodNumber;
5
+
6
+ export function numeric<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodNumber) => T): T;
7
+
8
+ export function numeric(meta: FormMeta, rule?: (z: any) => any) {
9
+ const baseRule = z.number();
10
+
11
+ return (rule ? rule(baseRule) : baseRule).meta({
12
+ ...meta,
13
+ renderAs: "numeric",
14
+ });
15
+ }
@@ -0,0 +1,15 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function object<T extends { [x: string]: z.ZodTypeAny }>(meta: FormMeta, shape: T): z.ZodObject<T>;
5
+
6
+ export function object<T extends { [x: string]: z.ZodTypeAny }, Z extends z.ZodTypeAny>(meta: FormMeta, shape: T, rule: (z: z.ZodObject<T>) => Z): Z;
7
+
8
+ export function object(meta: FormMeta, shape: any, rule?: (z: any) => any) {
9
+ const baseRule = z.object(shape);
10
+
11
+ return (rule ? rule(baseRule) : baseRule).meta({
12
+ ...meta,
13
+ renderAs: "object",
14
+ });
15
+ }
@@ -0,0 +1,73 @@
1
+ import { RecordId } from "surrealdb";
2
+ import z from "zod";
3
+ import { type FormMeta, type makeModel } from "..";
4
+
5
+ export type RelationType<Relation> = z.ZodPreprocess<
6
+ z.ZodPipe<
7
+ z.ZodObject<
8
+ Relation & {
9
+ id: z.ZodOptional<z.ZodAny>;
10
+ createdAt: z.ZodOptional<z.ZodPreprocess<z.ZodString>>;
11
+ updatedAt: z.ZodOptional<z.ZodPreprocess<z.ZodString>>;
12
+ _write: z.ZodOptional<z.ZodNullable<z.ZodOptional<z.ZodBoolean>>>;
13
+ },
14
+ z.core.$strip
15
+ >,
16
+ z.ZodTransform<
17
+ | (z.infer<Relation> & {
18
+ id: unknown;
19
+ createdAt?: string;
20
+ updtedAt?: string;
21
+ _write?: boolean;
22
+ })
23
+ | RecordId<string, any>,
24
+ Relation & {
25
+ id: unknown;
26
+ createdAt?: string;
27
+ updtedAt?: string;
28
+ _write?: boolean;
29
+ }
30
+ >
31
+ >
32
+ >;
33
+
34
+ type Relation<Model extends ReturnType<typeof makeModel>> = RelationType<ReturnType<Model["validation"]["partial"]>>;
35
+
36
+ export function relation<Model extends ReturnType<typeof makeModel>>(meta: FormMeta & { labelKey?: keyof Model["validation"]["shape"] }, model: Model): Relation<Model>;
37
+
38
+ export function relation<T extends z.ZodTypeAny, Model extends ReturnType<typeof makeModel>>(meta: FormMeta & { labelKey?: keyof Model["validation"]["shape"] }, model: Model, rule: (z: Relation<Model>) => T): T;
39
+
40
+ export function relation(meta: any, model: ReturnType<typeof makeModel>, rule?: (z: any) => any) {
41
+ const baseRule = z.preprocess(
42
+ (value) => {
43
+ if (typeof value === "string") {
44
+ return { id: value, _write: true };
45
+ }
46
+
47
+ if (value instanceof RecordId) {
48
+ return { id: value.toString().split(":")[1], _write: true };
49
+ }
50
+
51
+ return value;
52
+ },
53
+ model.validation
54
+ .extend({
55
+ id: z.any(),
56
+ createdAt: z.string(),
57
+ updatedAt: z.string(),
58
+ _write: z.boolean(),
59
+ })
60
+ .partial()
61
+ .transform((value) => {
62
+ if (value._write) return new RecordId(model.table, value.id);
63
+
64
+ return value;
65
+ }),
66
+ );
67
+
68
+ return (rule ? rule(baseRule) : baseRule).meta({
69
+ ...meta,
70
+ renderAs: "relation",
71
+ model: model.prefix,
72
+ });
73
+ }
@@ -0,0 +1,55 @@
1
+ import { RecordId } from "surrealdb";
2
+ import z from "zod";
3
+ import { type FormMeta, type makeModel, type RelationType } from "..";
4
+
5
+ type Return<Model extends ReturnType<typeof makeModel>> = z.ZodPreprocess<z.ZodArray<RelationType<ReturnType<Model["validation"]["partial"]>>>>;
6
+
7
+ export function relationMany<Model extends ReturnType<typeof makeModel>>(meta: FormMeta, model: Model): Return<Model>;
8
+
9
+ export function relationMany<T extends z.ZodTypeAny, Model extends ReturnType<typeof makeModel>>(meta: FormMeta, model: Model, rule: (z: z.ZodArray<RelationType<ReturnType<Model["validation"]["partial"]>>>) => T): T;
10
+
11
+ export function relationMany(meta: FormMeta, model: ReturnType<typeof makeModel>, rule?: (z: any) => any) {
12
+ const baseRule = z.array(
13
+ z.preprocess(
14
+ (value) => {
15
+ if (typeof value === "string") {
16
+ return { id: value, _write: true };
17
+ }
18
+
19
+ if (value instanceof RecordId) {
20
+ return { id: value.toString().split(":")[1], _write: true };
21
+ }
22
+
23
+ return value;
24
+ },
25
+ model.validation
26
+ .extend({
27
+ id: z.any(),
28
+ createdAt: z.string(),
29
+ updatedAt: z.string(),
30
+ _write: z.boolean(),
31
+ })
32
+ .partial()
33
+ .transform((value) => {
34
+ if (value._write) return new RecordId(model.table, value.id);
35
+
36
+ return value;
37
+ }),
38
+ ),
39
+ );
40
+
41
+ return z
42
+ .preprocess(
43
+ (value) => {
44
+ if (!!value && !Array.isArray(value)) return [value];
45
+
46
+ return value;
47
+ },
48
+ rule ? rule(baseRule) : baseRule,
49
+ )
50
+ .meta({
51
+ ...meta,
52
+ renderAs: "multi_relation",
53
+ model: model.prefix,
54
+ });
55
+ }
@@ -0,0 +1,20 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function richtext(meta: FormMeta): z.ZodPreprocess<z.ZodString>;
5
+
6
+ export function richtext<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodString) => T): z.ZodPreprocess<T>;
7
+
8
+ export function richtext(meta: FormMeta, rule?: (z: any) => any) {
9
+ const inner = rule ? rule(z.string()) : z.string();
10
+
11
+ return z
12
+ .preprocess((value) => {
13
+ if (!value) return undefined;
14
+ return value;
15
+ }, inner)
16
+ .meta({
17
+ ...meta,
18
+ renderAs: "richtext",
19
+ });
20
+ }
@@ -0,0 +1,15 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function select<const T extends readonly string[]>(meta: FormMeta, options: T): z.ZodEnum<{ [x in T[number]]: x }>;
5
+
6
+ export function select<const T extends readonly string[], Z extends z.ZodTypeAny>(meta: FormMeta, options: T, rule: (z: z.ZodEnum<{ [x in T[number]]: x }>) => Z): Z;
7
+
8
+ export function select(meta: FormMeta, options: string[], rule?: (z: any) => any) {
9
+ const baseRule = z.enum(options);
10
+
11
+ return (rule ? rule(baseRule) : baseRule).meta({
12
+ ...meta,
13
+ renderAs: "select",
14
+ });
15
+ }
@@ -0,0 +1,20 @@
1
+ import z from "zod";
2
+ import type { FormMeta } from "..";
3
+
4
+ export function text(meta: FormMeta): z.ZodPreprocess<z.ZodString>;
5
+
6
+ export function text<T extends z.ZodTypeAny>(meta: FormMeta, rule: (z: z.ZodString) => T): z.ZodPreprocess<T>;
7
+
8
+ export function text(meta: FormMeta, rule?: (z: any) => any) {
9
+ const inner = rule ? rule(z.string()) : z.string();
10
+
11
+ return z
12
+ .preprocess((value) => {
13
+ if (!value) return undefined;
14
+ return value;
15
+ }, inner)
16
+ .meta({
17
+ ...meta,
18
+ renderAs: "text",
19
+ });
20
+ }