@nexa-stack/framework 1.0.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 (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,203 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ interface CacheEntry {
5
+ value: unknown;
6
+ expires: number | null;
7
+ }
8
+
9
+ const memory = new Map<string, CacheEntry>();
10
+ let redis: any = null;
11
+
12
+ function driver(): "memory" | "file" | "redis" {
13
+ const d = process.env.CACHE_DRIVER || "memory";
14
+ if (d === "file" || d === "redis") return d;
15
+ return "memory";
16
+ }
17
+
18
+ function fileDir(): string {
19
+ const dir = join(process.cwd(), "storage", "cache");
20
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ function filePath(key: string): string {
25
+ const safe = key.replace(/[^a-zA-Z0-9._-]/g, "_");
26
+ return join(fileDir(), `${safe}.json`);
27
+ }
28
+
29
+ function isAlive(entry: CacheEntry | null | undefined): entry is CacheEntry {
30
+ if (!entry) return false;
31
+ if (entry.expires !== null && Date.now() > entry.expires) return false;
32
+ return true;
33
+ }
34
+
35
+ async function getRedis(): Promise<any> {
36
+ if (redis) return redis;
37
+ const url = process.env.REDIS_URL || "redis://127.0.0.1:6379";
38
+ try {
39
+ const { default: Redis } = await import("ioredis");
40
+ redis = new Redis(url);
41
+ return redis;
42
+ } catch {
43
+ throw new Error(
44
+ "CACHE_DRIVER=redis requires Redis. Set REDIS_URL and install optional peer ioredis."
45
+ );
46
+ }
47
+ }
48
+
49
+ export const cache = {
50
+ async get<T = unknown>(key: string): Promise<T | null> {
51
+ const d = driver();
52
+ if (d === "redis") {
53
+ const r = await getRedis();
54
+ const raw = await r.get(`nexa:${key}`);
55
+ if (raw == null) return null;
56
+ try {
57
+ return JSON.parse(raw) as T;
58
+ } catch {
59
+ return raw as T;
60
+ }
61
+ }
62
+ if (d === "file") {
63
+ try {
64
+ if (!existsSync(filePath(key))) return null;
65
+ const entry = JSON.parse(readFileSync(filePath(key), "utf-8")) as CacheEntry;
66
+ if (!isAlive(entry)) {
67
+ unlinkSync(filePath(key));
68
+ return null;
69
+ }
70
+ return entry.value as T;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ const entry = memory.get(key);
76
+ if (!isAlive(entry)) {
77
+ memory.delete(key);
78
+ return null;
79
+ }
80
+ return entry.value as T;
81
+ },
82
+
83
+ async set(key: string, value: unknown, seconds?: number): Promise<void> {
84
+ const d = driver();
85
+ if (d === "redis") {
86
+ const r = await getRedis();
87
+ const raw = JSON.stringify(value);
88
+ if (seconds) await r.set(`nexa:${key}`, raw, "EX", seconds);
89
+ else await r.set(`nexa:${key}`, raw);
90
+ return;
91
+ }
92
+ const entry: CacheEntry = {
93
+ value,
94
+ expires: seconds ? Date.now() + seconds * 1000 : null,
95
+ };
96
+ if (d === "file") {
97
+ writeFileSync(filePath(key), JSON.stringify(entry));
98
+ return;
99
+ }
100
+ memory.set(key, entry);
101
+ },
102
+
103
+ async forget(key: string): Promise<void> {
104
+ const d = driver();
105
+ if (d === "redis") {
106
+ const r = await getRedis();
107
+ await r.del(`nexa:${key}`);
108
+ return;
109
+ }
110
+ if (d === "file") {
111
+ try {
112
+ if (existsSync(filePath(key))) unlinkSync(filePath(key));
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ return;
117
+ }
118
+ memory.delete(key);
119
+ },
120
+
121
+ async flush(): Promise<void> {
122
+ const d = driver();
123
+ if (d === "redis") {
124
+ const r = await getRedis();
125
+ const keys = await r.keys("nexa:*");
126
+ if (keys.length) await r.del(...keys);
127
+ return;
128
+ }
129
+ if (d === "file") {
130
+ for (const f of readdirSync(fileDir())) {
131
+ try {
132
+ unlinkSync(join(fileDir(), f));
133
+ } catch {
134
+ /* ignore */
135
+ }
136
+ }
137
+ return;
138
+ }
139
+ memory.clear();
140
+ },
141
+
142
+ async remember<T>(key: string, seconds: number, fn: () => T | Promise<T>): Promise<T> {
143
+ const hit = await this.get<T>(key);
144
+ if (hit !== null) return hit;
145
+ const value = await fn();
146
+ await this.set(key, value, seconds);
147
+ return value;
148
+ },
149
+
150
+ /** Atomic increment — used by distributed rate limiting */
151
+ async incr(key: string, windowSeconds?: number): Promise<number> {
152
+ const d = driver();
153
+ if (d === "redis") {
154
+ const r = await getRedis();
155
+ const full = `nexa:${key}`;
156
+ const n = Number(await r.incr(full));
157
+ if (n === 1 && windowSeconds) {
158
+ try {
159
+ await r.expire(full, windowSeconds);
160
+ } catch {
161
+ /* ioredis API differences */
162
+ try {
163
+ await r.set(full, String(n), "EX", windowSeconds, "XX");
164
+ } catch {
165
+ /* ignore */
166
+ }
167
+ }
168
+ }
169
+ return n;
170
+ }
171
+ const current = (await this.get<number>(key)) ?? 0;
172
+ const next = current + 1;
173
+ await this.set(key, next, windowSeconds);
174
+ return next;
175
+ },
176
+
177
+ /** Delete keys with a prefix (list cache invalidation) */
178
+ async forgetPrefix(prefix: string): Promise<void> {
179
+ const d = driver();
180
+ if (d === "redis") {
181
+ const r = await getRedis();
182
+ const keys = await r.keys(`nexa:${prefix}*`);
183
+ if (keys?.length) await r.del(...keys);
184
+ return;
185
+ }
186
+ if (d === "file") {
187
+ const safePrefix = prefix.replace(/[^a-zA-Z0-9._-]/g, "_");
188
+ for (const f of readdirSync(fileDir())) {
189
+ if (f.startsWith(safePrefix)) {
190
+ try {
191
+ unlinkSync(join(fileDir(), f));
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ }
196
+ }
197
+ return;
198
+ }
199
+ for (const key of [...memory.keys()]) {
200
+ if (key.startsWith(prefix)) memory.delete(key);
201
+ }
202
+ },
203
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Thin fetch client for a future Nexa frontend — no UI, just API helpers.
3
+ */
4
+ export interface NexaClientOptions {
5
+ baseUrl?: string;
6
+ token?: string | (() => string | null | undefined);
7
+ }
8
+
9
+ export function createClient(opts: NexaClientOptions = {}) {
10
+ const base = (opts.baseUrl ?? "").replace(/\/+$/, "");
11
+
12
+ async function request(method: string, path: string, body?: unknown) {
13
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
14
+ const token = typeof opts.token === "function" ? opts.token() : opts.token;
15
+ if (token) headers.Authorization = `Bearer ${token}`;
16
+ const res = await fetch(`${base}${path}`, {
17
+ method,
18
+ headers,
19
+ body: body === undefined ? undefined : JSON.stringify(body),
20
+ });
21
+ const json = await res.json().catch(() => ({}));
22
+ if (!res.ok) {
23
+ const err = new Error((json as { error?: string }).error || res.statusText);
24
+ (err as Error & { status: number; body: unknown }).status = res.status;
25
+ (err as Error & { status: number; body: unknown }).body = json;
26
+ throw err;
27
+ }
28
+ return json;
29
+ }
30
+
31
+ return {
32
+ get: (path: string) => request("GET", path),
33
+ post: (path: string, body?: unknown) => request("POST", path, body),
34
+ put: (path: string, body?: unknown) => request("PUT", path, body),
35
+ del: (path: string) => request("DELETE", path),
36
+ resource(name: string) {
37
+ const basePath = `/api/${name}`;
38
+ return {
39
+ list: (qs = "") => request("GET", `${basePath}${qs}`),
40
+ get: (id: number | string) => request("GET", `${basePath}/${id}`),
41
+ create: (body: unknown) => request("POST", basePath, body),
42
+ update: (id: number | string, body: unknown) => request("PUT", `${basePath}/${id}`, body),
43
+ remove: (id: number | string) => request("DELETE", `${basePath}/${id}`),
44
+ };
45
+ },
46
+ login: (email: string, password: string) =>
47
+ request("POST", "/api/auth/login", { email, password }),
48
+ };
49
+ }
@@ -0,0 +1,97 @@
1
+ import { join } from "path";
2
+ import { loadEnv, config, configNumber, assertProductionReady, isAuthRegisterEnabled } from "./config.js";
3
+ import { getResources } from "./registry.js";
4
+ import { loadResources } from "./loader.js";
5
+ import { moduleDir } from "./runtime.js";
6
+ import { dbConnection, type Database } from "../../database/src/database.js";
7
+ import { Router, createServer } from "../../server/src/router.js";
8
+ import { use, rateLimit, cors, clearMiddleware } from "../../server/src/middleware.js";
9
+ import { setupAuth, seedAdmin } from "../../auth/src/index.js";
10
+ import { runMigrations, runSeeders } from "./migrate.js";
11
+ import { setupQueue, registerBuiltInJobs } from "../../queue/src/index.js";
12
+ import { setupNotifications } from "../../notifications/src/index.js";
13
+ import { bootPlugins } from "../../plugins/src/index.js";
14
+ import { log } from "../../log/src/index.js";
15
+
16
+ loadEnv();
17
+
18
+ let dbInstance: Database | null = null;
19
+
20
+ /** Framework package `public/` — used when the app has no local copy */
21
+ function packagePublicDir(): string {
22
+ return join(moduleDir(import.meta.url), "../../../public");
23
+ }
24
+
25
+ export async function start(dbPath?: string, port?: number) {
26
+ assertProductionReady();
27
+ const path = dbPath ?? dbConnection();
28
+ const serverPort = port ?? configNumber("PORT");
29
+
30
+ await loadResources();
31
+
32
+ dbInstance = await runMigrations(path);
33
+ await setupQueue(dbInstance);
34
+ registerBuiltInJobs();
35
+ await setupNotifications(dbInstance);
36
+ await bootPlugins();
37
+
38
+ await setupAuth({ secret: config("APP_SECRET") }, dbInstance);
39
+ if (config("APP_SECRET") === "change-me") {
40
+ log.warn("APP_SECRET is the default — set a strong secret before any public deploy");
41
+ }
42
+ await seedAdmin(dbInstance);
43
+ await runSeeders(dbInstance);
44
+
45
+ clearMiddleware();
46
+ use(cors());
47
+ const limit = Number(config("RATE_LIMIT") || 120);
48
+ if (limit > 0) use(rateLimit(limit, 60_000));
49
+
50
+ const router = new Router(dbInstance, join(process.cwd(), "public"), packagePublicDir());
51
+ for (const def of getResources()) router.registerResource(def);
52
+ router.registerAuthRoutes();
53
+ router.registerNotificationRoutes();
54
+ const { getCustomRoutes } = await import("./routes.js");
55
+ router.registerCustomRoutes([...getCustomRoutes()]);
56
+
57
+ const { startHotReload } = await import("./reload.js");
58
+ startHotReload(router, dbInstance);
59
+
60
+ const server = await createServer(router, serverPort);
61
+ const { setDevPort } = await import("./dev.js");
62
+ setDevPort(server.port);
63
+ log.info(`Nexa → http://localhost:${server.port} (${dbInstance.dialect})`);
64
+ if (config("NODE_ENV") !== "production") {
65
+ log.info("Dev bar → open /admin or /docs (bottom toolbar)");
66
+ }
67
+ return server;
68
+ }
69
+
70
+ export function getDb(): Database | null {
71
+ return dbInstance;
72
+ }
73
+
74
+ /** Open / migrate DB (tests + CLI). Also exposes db.from / db.transaction */
75
+ async function openDb(path?: string): Promise<Database> {
76
+ dbInstance = await runMigrations(path ?? dbConnection());
77
+ return dbInstance;
78
+ }
79
+
80
+ export const db = Object.assign(openDb, {
81
+ from(table: string) {
82
+ const instance = getDb();
83
+ if (!instance) throw new Error("Call start() or await db() first");
84
+ return instance.from(table);
85
+ },
86
+ table(table: string) {
87
+ return db.from(table);
88
+ },
89
+ transaction<T>(fn: (tx: Database) => Promise<T>): Promise<T> {
90
+ const instance = getDb();
91
+ if (!instance) throw new Error("Call start() or await db() first");
92
+ return instance.transaction(fn);
93
+ },
94
+ });
95
+
96
+ export const connect = start;
97
+ export const serve = start;
@@ -0,0 +1,55 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ const defaults: Record<string, string> = {
5
+ DB_PATH: "./app.db",
6
+ PORT: "3333",
7
+ APP_SECRET: "change-me",
8
+ APP_LOCALE: "en",
9
+ APP_URL: "http://localhost:3333",
10
+ MAIL_DRIVER: "log",
11
+ MAIL_FROM: "nexa@localhost",
12
+ LOG_LEVEL: "debug",
13
+ CACHE_DRIVER: "memory",
14
+ AUTH_RATE_LIMIT: "10",
15
+ };
16
+
17
+ export function loadEnv(root = process.cwd()): void {
18
+ const envPath = join(root, ".env");
19
+ if (!existsSync(envPath)) return;
20
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.startsWith("#")) continue;
23
+ const i = trimmed.indexOf("=");
24
+ if (i === -1) continue;
25
+ const key = trimmed.slice(0, i).trim();
26
+ const val = trimmed.slice(i + 1).trim();
27
+ if (!process.env[key]) process.env[key] = val;
28
+ }
29
+ }
30
+
31
+ export function config(key: string): string {
32
+ return process.env[key] ?? defaults[key] ?? "";
33
+ }
34
+
35
+ export function configNumber(key: string): number {
36
+ return Number(config(key));
37
+ }
38
+
39
+ /** Public sign-up: enabled in dev; disabled in production unless AUTH_REGISTER=true. */
40
+ export function isAuthRegisterEnabled(): boolean {
41
+ const raw = process.env.AUTH_REGISTER;
42
+ if (raw?.toLowerCase() === "true") return true;
43
+ if (raw?.toLowerCase() === "false") return false;
44
+ return config("NODE_ENV") !== "production";
45
+ }
46
+
47
+ export function assertProductionReady(): void {
48
+ if (config("NODE_ENV") !== "production") return;
49
+ const secret = config("APP_SECRET");
50
+ if (!secret || secret === "change-me" || secret.length < 32) {
51
+ throw new Error(
52
+ "Production requires APP_SECRET — set a random string of at least 32 characters in .env"
53
+ );
54
+ }
55
+ }
@@ -0,0 +1,104 @@
1
+ import { config } from "./config.js";
2
+ import { getResources } from "./registry.js";
3
+
4
+ export interface DevRequestLog {
5
+ method: string;
6
+ path: string;
7
+ status: number;
8
+ ms: number;
9
+ at: string;
10
+ }
11
+
12
+ export interface DevRouteEntry {
13
+ method: string;
14
+ path: string;
15
+ group: string;
16
+ }
17
+
18
+ const MAX_REQUESTS = 30;
19
+
20
+ let startedAt = new Date().toISOString();
21
+ let port = 3333;
22
+ let lastReloadAt: string | null = null;
23
+ let lastReloadError: string | null = null;
24
+ let lastReloadResources = 0;
25
+ const recentRequests: DevRequestLog[] = [];
26
+
27
+ export function isDevMode(): boolean {
28
+ return config("NODE_ENV") !== "production";
29
+ }
30
+
31
+ export function isHotReloadEnabled(): boolean {
32
+ return isDevMode() && config("HOT_RELOAD") !== "false";
33
+ }
34
+
35
+ export function resetDevState(): void {
36
+ startedAt = new Date().toISOString();
37
+ lastReloadAt = null;
38
+ lastReloadError = null;
39
+ lastReloadResources = 0;
40
+ recentRequests.length = 0;
41
+ }
42
+
43
+ export function setDevPort(value: number): void {
44
+ port = value;
45
+ }
46
+
47
+ export function recordDevReload(resourceCount: number, err?: string): void {
48
+ lastReloadAt = new Date().toISOString();
49
+ lastReloadResources = resourceCount;
50
+ lastReloadError = err ?? null;
51
+ }
52
+
53
+ export function recordDevRequest(
54
+ method: string,
55
+ path: string,
56
+ status: number,
57
+ ms: number
58
+ ): void {
59
+ if (!isDevMode()) return;
60
+ if (path === "/dev-bar.js" || path.startsWith("/api/dev/")) return;
61
+
62
+ recentRequests.unshift({
63
+ method,
64
+ path,
65
+ status,
66
+ ms: Math.round(ms),
67
+ at: new Date().toISOString(),
68
+ });
69
+ if (recentRequests.length > MAX_REQUESTS) recentRequests.pop();
70
+ }
71
+
72
+ export function getDevStatus(routeCount = 0) {
73
+ return {
74
+ dev: true,
75
+ port,
76
+ started_at: startedAt,
77
+ hot_reload: isHotReloadEnabled(),
78
+ node_env: config("NODE_ENV") || "development",
79
+ dialect: null as string | null,
80
+ resources: getResources().length,
81
+ routes: routeCount,
82
+ last_reload_at: lastReloadAt,
83
+ last_reload_error: lastReloadError,
84
+ last_reload_resources: lastReloadResources,
85
+ recent_requests: [...recentRequests],
86
+ };
87
+ }
88
+
89
+ export function groupRouteKey(key: string, resourceKeys: Set<string>, customKeys: Set<string>): DevRouteEntry {
90
+ const space = key.indexOf(" ");
91
+ const method = key.slice(0, space);
92
+ const path = key.slice(space + 1);
93
+
94
+ let group = "api";
95
+ if (resourceKeys.has(key)) group = "resource";
96
+ else if (customKeys.has(key)) group = "custom";
97
+ else if (path.startsWith("/api/auth")) group = "auth";
98
+ else if (path.includes("notifications")) group = "notifications";
99
+ else if (path === "/health" || path === "/ready" || path === "/api/health" || path === "/api/ready") {
100
+ group = "probe";
101
+ } else if (path.startsWith("/api/dev")) group = "dev";
102
+
103
+ return { method, path, group };
104
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ string,
4
+ number,
5
+ email,
6
+ select,
7
+ date,
8
+ belongsTo,
9
+ hasMany,
10
+ belongsToMany,
11
+ resolveFields,
12
+ foreignKeyFor,
13
+ pivotTableFor,
14
+ } from "./fields.js";
15
+ import { checkPolicy, policyFromRole } from "./policy.js";
16
+
17
+ describe("fields", () => {
18
+ test("string required", () => {
19
+ const fields = resolveFields({ name: string().required() });
20
+ expect(fields.name.required).toBe(true);
21
+ expect(fields.name.type).toBe("string");
22
+ });
23
+
24
+ test("belongsTo sets relation column", () => {
25
+ const fields = resolveFields({ category: belongsTo("categories") });
26
+ expect(fields.category.type).toBe("relation");
27
+ expect(fields.category.column).toBe("category_id");
28
+ expect(fields.category.relation).toBe("categories");
29
+ });
30
+
31
+ test("belongsTo field ending with _id does not double suffix", () => {
32
+ const fields = resolveFields({ teacher_id: belongsTo("teachers") });
33
+ expect(fields.teacher_id.column).toBe("teacher_id");
34
+ });
35
+
36
+ test("reserved field name throws clear error", () => {
37
+ expect(() => resolveFields({ order: number() })).toThrow(/reserved word/);
38
+ });
39
+
40
+ test("hasMany sets relation mode", () => {
41
+ const fields = resolveFields({ items: hasMany("order_items") });
42
+ expect(fields.items.type).toBe("hasMany");
43
+ expect(fields.items.relation).toBe("order_items");
44
+ });
45
+
46
+ test("foreignKeyFor parent table", () => {
47
+ expect(foreignKeyFor("orders")).toBe("order_id");
48
+ expect(foreignKeyFor("clients")).toBe("client_id");
49
+ });
50
+
51
+ test("number min max", () => {
52
+ const fields = resolveFields({ age: number().min(1).max(100) });
53
+ expect(fields.age.min).toBe(1);
54
+ expect(fields.age.max).toBe(100);
55
+ });
56
+
57
+ test("email type", () => {
58
+ const fields = resolveFields({ mail: email().required() });
59
+ expect(fields.mail.type).toBe("email");
60
+ });
61
+
62
+ test("belongsToMany pivot name", () => {
63
+ expect(pivotTableFor("posts", "tags")).toBe("post_tag");
64
+ const fields = resolveFields({ tags: belongsToMany("tags") });
65
+ expect(fields.tags.type).toBe("belongsToMany");
66
+ });
67
+ });
68
+
69
+ describe("policy", () => {
70
+ test("policyFromRole requires role", () => {
71
+ const policy = policyFromRole("manager");
72
+ expect(checkPolicy(policy, "view", null)).toBe(false);
73
+ expect(checkPolicy(policy, "view", { id: 1, email: "a", role: "manager" })).toBe(true);
74
+ });
75
+
76
+ test("custom policy function", () => {
77
+ const policy = { update: (user) => user?.role === "admin" };
78
+ expect(checkPolicy(policy, "update", { id: 1, email: "a", role: "user" })).toBe(false);
79
+ expect(checkPolicy(policy, "update", { id: 1, email: "a", role: "admin" })).toBe(true);
80
+ });
81
+ });