@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.
- package/.env.example +46 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/nexa.mjs +41 -0
- package/bin/nexa.ts +334 -0
- package/docs/AI.md +69 -0
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/EXAMPLES.md +114 -0
- package/docs/FRAMEWORK.md +226 -0
- package/docs/LANGUAGE.md +39 -0
- package/docs/README.md +7 -0
- package/docs/READY.md +51 -0
- package/docs/REFERENCE.md +255 -0
- package/docs/START.md +98 -0
- package/docs/advanced.md +97 -0
- package/docs/authentication.md +54 -0
- package/docs/cli.md +15 -0
- package/docs/compare.md +51 -0
- package/docs/configuration.md +65 -0
- package/docs/database.md +396 -0
- package/docs/installation.md +57 -0
- package/docs/localization.md +47 -0
- package/docs/resources.md +75 -0
- package/docs/routing.md +59 -0
- package/docs/seeding.md +33 -0
- package/docs/services.md +146 -0
- package/package.json +77 -0
- package/packages/auth/src/auth.test.ts +23 -0
- package/packages/auth/src/auth.ts +287 -0
- package/packages/auth/src/index.ts +17 -0
- package/packages/cache/src/index.ts +203 -0
- package/packages/client/src/index.ts +49 -0
- package/packages/core/src/app.ts +97 -0
- package/packages/core/src/config.ts +55 -0
- package/packages/core/src/dev.ts +104 -0
- package/packages/core/src/fields.test.ts +81 -0
- package/packages/core/src/fields.ts +309 -0
- package/packages/core/src/index.ts +60 -0
- package/packages/core/src/lang.ts +79 -0
- package/packages/core/src/loader.ts +42 -0
- package/packages/core/src/migrate.ts +91 -0
- package/packages/core/src/policy.ts +36 -0
- package/packages/core/src/registry.ts +17 -0
- package/packages/core/src/reload.ts +92 -0
- package/packages/core/src/resource.test.ts +32 -0
- package/packages/core/src/resource.ts +87 -0
- package/packages/core/src/routes.ts +22 -0
- package/packages/core/src/runtime.ts +11 -0
- package/packages/database/src/builder.ts +266 -0
- package/packages/database/src/database.ts +252 -0
- package/packages/database/src/dialect.ts +186 -0
- package/packages/database/src/index.ts +6 -0
- package/packages/database/src/mysql.ts +114 -0
- package/packages/database/src/postgres.ts +117 -0
- package/packages/database/src/query.ts +115 -0
- package/packages/database/src/sqlite.ts +216 -0
- package/packages/database/src/types.ts +104 -0
- package/packages/events/src/index.ts +17 -0
- package/packages/export/src/index.ts +36 -0
- package/packages/log/src/index.ts +38 -0
- package/packages/mail/src/index.ts +130 -0
- package/packages/notifications/src/index.ts +84 -0
- package/packages/plugins/src/index.ts +39 -0
- package/packages/queue/src/index.ts +185 -0
- package/packages/queue/src/jobs.ts +9 -0
- package/packages/schedule/src/index.ts +64 -0
- package/packages/server/src/index.ts +1 -0
- package/packages/server/src/middleware.ts +143 -0
- package/packages/server/src/query.ts +40 -0
- package/packages/server/src/router.ts +813 -0
- package/packages/sms/src/index.ts +33 -0
- package/packages/storage/src/upload.ts +36 -0
- package/packages/testing/src/index.ts +67 -0
- package/packages/validation/src/index.ts +1 -0
- package/packages/validation/src/validate.test.ts +35 -0
- package/packages/validation/src/validate.ts +112 -0
- package/public/admin.html +369 -0
- package/public/compare.html +66 -0
- package/public/dev-bar.js +213 -0
- package/public/docs.html +315 -0
- package/public/index.html +66 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export interface QueryOpts {
|
|
2
|
+
limit?: number;
|
|
3
|
+
offset?: number;
|
|
4
|
+
search?: string;
|
|
5
|
+
searchFields?: string[];
|
|
6
|
+
filters?: Record<string, string>;
|
|
7
|
+
sort?: string;
|
|
8
|
+
order?: "asc" | "desc";
|
|
9
|
+
/** Soft deletes: exclude = active only (default for soft resources), only = trash, with = all */
|
|
10
|
+
softDelete?: "exclude" | "only" | "with";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PaginatedResult<T> {
|
|
14
|
+
data: T[];
|
|
15
|
+
meta: {
|
|
16
|
+
current_page: number;
|
|
17
|
+
from: number | null;
|
|
18
|
+
last_page: number;
|
|
19
|
+
per_page: number;
|
|
20
|
+
to: number | null;
|
|
21
|
+
total: number;
|
|
22
|
+
};
|
|
23
|
+
links: {
|
|
24
|
+
first: string | null;
|
|
25
|
+
last: string | null;
|
|
26
|
+
prev: string | null;
|
|
27
|
+
next: string | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const IDENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
/** Reject anything that is not a plain SQL identifier */
|
|
34
|
+
export function safeIdent(name: string, fallback = "id"): string {
|
|
35
|
+
return IDENT_RE.test(name) ? name : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildQuery(
|
|
39
|
+
table: string,
|
|
40
|
+
opts: QueryOpts
|
|
41
|
+
): { sql: string; countSql: string; params: unknown[] } {
|
|
42
|
+
const tableName = safeIdent(table, "");
|
|
43
|
+
if (!tableName) throw new Error("Invalid table name");
|
|
44
|
+
|
|
45
|
+
const params: unknown[] = [];
|
|
46
|
+
const conditions: string[] = [];
|
|
47
|
+
|
|
48
|
+
if (opts.search && opts.searchFields?.length) {
|
|
49
|
+
const fields = opts.searchFields.map((f) => safeIdent(f, "")).filter(Boolean);
|
|
50
|
+
if (fields.length) {
|
|
51
|
+
const parts = fields.map((f) => `${f} LIKE ?`);
|
|
52
|
+
conditions.push(`(${parts.join(" OR ")})`);
|
|
53
|
+
for (const _ of fields) params.push(`%${opts.search}%`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (opts.filters) {
|
|
58
|
+
for (const [key, val] of Object.entries(opts.filters)) {
|
|
59
|
+
if (val === "" || val == null) continue;
|
|
60
|
+
const col = safeIdent(key, "");
|
|
61
|
+
if (!col) continue;
|
|
62
|
+
conditions.push(`${col} = ?`);
|
|
63
|
+
params.push(val);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (opts.softDelete === "exclude") {
|
|
68
|
+
conditions.push("(deleted_at IS NULL OR deleted_at = '')");
|
|
69
|
+
} else if (opts.softDelete === "only") {
|
|
70
|
+
conditions.push("(deleted_at IS NOT NULL AND deleted_at != '')");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
74
|
+
const sortCol = safeIdent(opts.sort ?? "id", "id");
|
|
75
|
+
const sortOrder = opts.order === "asc" ? "ASC" : "DESC";
|
|
76
|
+
const limit = opts.limit ?? 20;
|
|
77
|
+
const offset = opts.offset ?? 0;
|
|
78
|
+
|
|
79
|
+
const sql = `SELECT * FROM ${tableName} ${where} ORDER BY ${sortCol} ${sortOrder} LIMIT ? OFFSET ?`;
|
|
80
|
+
const countSql = `SELECT COUNT(*) as total FROM ${tableName} ${where}`;
|
|
81
|
+
|
|
82
|
+
return { sql, countSql, params: [...params, limit, offset] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function paginate<T extends { id?: unknown }>(
|
|
86
|
+
items: T[],
|
|
87
|
+
total: number,
|
|
88
|
+
page: number,
|
|
89
|
+
perPage: number,
|
|
90
|
+
baseUrl: string
|
|
91
|
+
): PaginatedResult<T> {
|
|
92
|
+
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
93
|
+
const from = total === 0 ? null : (page - 1) * perPage + 1;
|
|
94
|
+
const to = total === 0 ? null : from! + items.length - 1;
|
|
95
|
+
|
|
96
|
+
const link = (p: number) => `${baseUrl}?page=${p}&limit=${perPage}`;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
data: items,
|
|
100
|
+
meta: {
|
|
101
|
+
current_page: page,
|
|
102
|
+
from,
|
|
103
|
+
last_page: lastPage,
|
|
104
|
+
per_page: perPage,
|
|
105
|
+
to,
|
|
106
|
+
total,
|
|
107
|
+
},
|
|
108
|
+
links: {
|
|
109
|
+
first: link(1),
|
|
110
|
+
last: link(lastPage),
|
|
111
|
+
prev: page > 1 ? link(page - 1) : null,
|
|
112
|
+
next: page < lastPage ? link(page + 1) : null,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import type { ResourceDefinition } from "../../core/src/resource.js";
|
|
5
|
+
import { fieldColumn } from "../../core/src/fields.js";
|
|
6
|
+
import type { QueryOpts } from "./query.js";
|
|
7
|
+
import { buildQuery } from "./query.js";
|
|
8
|
+
import type { DatabaseDriver, DbRow } from "./types.js";
|
|
9
|
+
import { buildColumns } from "./types.js";
|
|
10
|
+
|
|
11
|
+
type SqlJsDatabase = {
|
|
12
|
+
run: (sql: string, params?: unknown[]) => void;
|
|
13
|
+
exec: (sql: string) => { columns: string[]; values: unknown[][] }[];
|
|
14
|
+
prepare: (sql: string) => {
|
|
15
|
+
bind: (params: unknown[]) => void;
|
|
16
|
+
step: () => boolean;
|
|
17
|
+
getAsObject: () => Record<string, unknown>;
|
|
18
|
+
free: () => void;
|
|
19
|
+
};
|
|
20
|
+
export: () => Uint8Array;
|
|
21
|
+
close: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function bindValue(v: unknown): unknown {
|
|
25
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
26
|
+
if (v === undefined) return null;
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bindParams(params: unknown[]): unknown[] {
|
|
31
|
+
return params.map(bindValue);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* SQLite via sql.js (WASM) — no Visual Studio / node-gyp / native build.
|
|
36
|
+
* Fine for local + small apps. On Hostinger prefer MySQL via DATABASE_URL.
|
|
37
|
+
*/
|
|
38
|
+
export class SqliteDriver implements DatabaseDriver {
|
|
39
|
+
readonly dialect = "sqlite" as const;
|
|
40
|
+
private db: SqlJsDatabase | null = null;
|
|
41
|
+
private dirty = false;
|
|
42
|
+
|
|
43
|
+
constructor(private path: string) {}
|
|
44
|
+
|
|
45
|
+
async connect(): Promise<void> {
|
|
46
|
+
const initSqlJs = (await import("sql.js")).default;
|
|
47
|
+
const require = createRequire(import.meta.url);
|
|
48
|
+
const distDir = dirname(require.resolve("sql.js"));
|
|
49
|
+
const SQL = await initSqlJs({
|
|
50
|
+
locateFile: (file: string) => join(distDir, file),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (this.path === ":memory:" || !existsSync(this.path)) {
|
|
54
|
+
this.db = new SQL.Database() as SqlJsDatabase;
|
|
55
|
+
if (this.path !== ":memory:") {
|
|
56
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
57
|
+
this.dirty = true;
|
|
58
|
+
this.persist();
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
const fileBuffer = readFileSync(this.path);
|
|
62
|
+
this.db = new SQL.Database(fileBuffer) as SqlJsDatabase;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private assertDb(): SqlJsDatabase {
|
|
69
|
+
if (!this.db) throw new Error("SQLite not connected");
|
|
70
|
+
return this.db;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private persist(): void {
|
|
74
|
+
if (!this.db || this.path === ":memory:" || !this.dirty) return;
|
|
75
|
+
writeFileSync(this.path, Buffer.from(this.db.export()));
|
|
76
|
+
this.dirty = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private markDirty(): void {
|
|
80
|
+
this.dirty = true;
|
|
81
|
+
this.persist();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private all(sql: string, params: unknown[] = []): DbRow[] {
|
|
85
|
+
const db = this.assertDb();
|
|
86
|
+
const stmt = db.prepare(sql);
|
|
87
|
+
try {
|
|
88
|
+
if (params.length) stmt.bind(bindParams(params));
|
|
89
|
+
const rows: DbRow[] = [];
|
|
90
|
+
while (stmt.step()) {
|
|
91
|
+
rows.push(stmt.getAsObject() as DbRow);
|
|
92
|
+
}
|
|
93
|
+
return rows;
|
|
94
|
+
} finally {
|
|
95
|
+
stmt.free();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private one(sql: string, params: unknown[] = []): DbRow | null {
|
|
100
|
+
return this.all(sql, params)[0] ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async createTable(resource: ResourceDefinition): Promise<void> {
|
|
104
|
+
const db = this.assertDb();
|
|
105
|
+
const columns = buildColumns(resource);
|
|
106
|
+
db.run(`
|
|
107
|
+
CREATE TABLE IF NOT EXISTS ${resource.table} (
|
|
108
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
109
|
+
${columns ? `${columns},` : ""}
|
|
110
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
111
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
this.markDirty();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
findAll(table: string, opts: QueryOpts = {}): DbRow[] {
|
|
118
|
+
if (!opts.search && !opts.filters && !opts.sort && !opts.softDelete) {
|
|
119
|
+
const limit = opts.limit ?? 100;
|
|
120
|
+
const offset = opts.offset ?? 0;
|
|
121
|
+
return this.all(`SELECT * FROM ${table} ORDER BY id DESC LIMIT ? OFFSET ?`, [
|
|
122
|
+
limit,
|
|
123
|
+
offset,
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
const { sql, params } = buildQuery(table, opts);
|
|
127
|
+
return this.all(sql, params);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
count(table: string, opts: QueryOpts = {}): number {
|
|
131
|
+
if (!opts.search && !opts.filters && !opts.softDelete) {
|
|
132
|
+
const row = this.one(`SELECT COUNT(*) as total FROM ${table}`);
|
|
133
|
+
return Number(row?.total ?? 0);
|
|
134
|
+
}
|
|
135
|
+
const { countSql, params } = buildQuery(table, { ...opts, limit: 1, offset: 0 });
|
|
136
|
+
const row = this.one(countSql, params.slice(0, -2));
|
|
137
|
+
return Number(row?.total ?? 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
findById(table: string, id: number): DbRow | null {
|
|
141
|
+
return this.one(`SELECT * FROM ${table} WHERE id = ?`, [id]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
insert(table: string, data: Record<string, unknown>): DbRow {
|
|
145
|
+
const db = this.assertDb();
|
|
146
|
+
const keys = Object.keys(data);
|
|
147
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
148
|
+
const values = keys.map((k) => bindValue(data[k]));
|
|
149
|
+
try {
|
|
150
|
+
const row = this.one(
|
|
151
|
+
`INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders}) RETURNING *`,
|
|
152
|
+
values
|
|
153
|
+
);
|
|
154
|
+
this.markDirty();
|
|
155
|
+
if (row) return row;
|
|
156
|
+
} catch {
|
|
157
|
+
/* older sql.js / no RETURNING — fallback */
|
|
158
|
+
}
|
|
159
|
+
db.run(
|
|
160
|
+
`INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders})`,
|
|
161
|
+
values
|
|
162
|
+
);
|
|
163
|
+
this.markDirty();
|
|
164
|
+
const idRow = this.one(`SELECT last_insert_rowid() as id`);
|
|
165
|
+
const id = Number(idRow?.id ?? 0);
|
|
166
|
+
return this.findById(table, id) ?? { id, ...data };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
update(table: string, id: number, data: Record<string, unknown>): DbRow | null {
|
|
170
|
+
const db = this.assertDb();
|
|
171
|
+
const keys = Object.keys(data);
|
|
172
|
+
if (keys.length === 0) return this.findById(table, id);
|
|
173
|
+
const setClause = keys.map((k) => `${k} = ?`).join(", ");
|
|
174
|
+
db.run(`UPDATE ${table} SET ${setClause}, updated_at = datetime('now') WHERE id = ?`, [
|
|
175
|
+
...keys.map((k) => bindValue(data[k])),
|
|
176
|
+
id,
|
|
177
|
+
]);
|
|
178
|
+
this.markDirty();
|
|
179
|
+
return this.findById(table, id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
delete(table: string, id: number): boolean {
|
|
183
|
+
const before = this.findById(table, id);
|
|
184
|
+
if (!before) return false;
|
|
185
|
+
this.assertDb().run(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
|
186
|
+
this.markDirty();
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
query(sql: string, params: unknown[] = []): DbRow[] {
|
|
191
|
+
return this.all(sql, params);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getOne(sql: string, params: unknown[] = []): DbRow | null {
|
|
195
|
+
return this.one(sql, params);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
run(sql: string, params: unknown[] = []): void {
|
|
199
|
+
this.assertDb().run(sql, bindParams(params));
|
|
200
|
+
this.markDirty();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
close(): void {
|
|
204
|
+
if (this.db) {
|
|
205
|
+
this.persist();
|
|
206
|
+
this.db.close();
|
|
207
|
+
this.db = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function searchFieldsFor(resource: ResourceDefinition): string[] {
|
|
213
|
+
return Object.entries(resource.fields)
|
|
214
|
+
.filter(([, f]) => f.type === "string" || f.type === "email" || f.type === "text")
|
|
215
|
+
.map(([name, f]) => fieldColumn(name, f));
|
|
216
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { FieldDef } from "../../core/src/fields.js";
|
|
2
|
+
import { fieldColumn, isStoredField } from "../../core/src/fields.js";
|
|
3
|
+
import type { ResourceDefinition } from "../../core/src/resource.js";
|
|
4
|
+
import type { QueryOpts } from "./query.js";
|
|
5
|
+
|
|
6
|
+
export type { QueryOpts };
|
|
7
|
+
|
|
8
|
+
export interface DbRow {
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DbDialect = "sqlite" | "postgres" | "mysql";
|
|
13
|
+
|
|
14
|
+
export interface DatabaseDriver {
|
|
15
|
+
dialect: DbDialect;
|
|
16
|
+
connect(): Promise<void>;
|
|
17
|
+
createTable(resource: ResourceDefinition): Promise<void>;
|
|
18
|
+
findAll(table: string, opts?: QueryOpts): DbRow[] | Promise<DbRow[]>;
|
|
19
|
+
count(table: string, opts?: QueryOpts): number | Promise<number>;
|
|
20
|
+
findById(table: string, id: number): DbRow | null | Promise<DbRow | null>;
|
|
21
|
+
insert(table: string, data: Record<string, unknown>): DbRow | Promise<DbRow>;
|
|
22
|
+
update(
|
|
23
|
+
table: string,
|
|
24
|
+
id: number,
|
|
25
|
+
data: Record<string, unknown>
|
|
26
|
+
): DbRow | null | Promise<DbRow | null>;
|
|
27
|
+
delete(table: string, id: number): boolean | Promise<boolean>;
|
|
28
|
+
query(sql: string, params?: unknown[]): DbRow[] | Promise<DbRow[]>;
|
|
29
|
+
getOne(sql: string, params?: unknown[]): DbRow | null | Promise<DbRow | null>;
|
|
30
|
+
run(sql: string, params?: unknown[]): void | Promise<void>;
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildColumns(resource: ResourceDefinition): string {
|
|
35
|
+
const cols = Object.entries(resource.fields)
|
|
36
|
+
.filter(([, field]) => isStoredField(field))
|
|
37
|
+
.map(([name, field]) => `${fieldColumn(name, field!)} ${sqlType(field)}`);
|
|
38
|
+
if (resource.softDelete) cols.push("deleted_at TEXT");
|
|
39
|
+
return cols.join(", ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildPgColumns(resource: ResourceDefinition): string {
|
|
43
|
+
const cols = Object.entries(resource.fields)
|
|
44
|
+
.filter(([, field]) => isStoredField(field))
|
|
45
|
+
.map(([name, field]) => `${fieldColumn(name, field!)} ${pgSqlType(field)}`);
|
|
46
|
+
if (resource.softDelete) cols.push("deleted_at TIMESTAMPTZ");
|
|
47
|
+
return cols.join(", ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildMySqlColumns(resource: ResourceDefinition): string {
|
|
51
|
+
const cols = Object.entries(resource.fields)
|
|
52
|
+
.filter(([, field]) => isStoredField(field))
|
|
53
|
+
.map(([name, field]) => `\`${fieldColumn(name, field!)}\` ${mySqlType(field)}`);
|
|
54
|
+
if (resource.softDelete) cols.push("`deleted_at` VARCHAR(50) NULL");
|
|
55
|
+
return cols.join(", ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sqlType(field: FieldDef): string {
|
|
59
|
+
switch (field.type) {
|
|
60
|
+
case "number":
|
|
61
|
+
case "money":
|
|
62
|
+
return "REAL";
|
|
63
|
+
case "boolean":
|
|
64
|
+
return "INTEGER";
|
|
65
|
+
case "relation":
|
|
66
|
+
return "INTEGER";
|
|
67
|
+
case "file":
|
|
68
|
+
case "image":
|
|
69
|
+
return "TEXT";
|
|
70
|
+
default:
|
|
71
|
+
return "TEXT";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function pgSqlType(field: FieldDef): string {
|
|
76
|
+
switch (field.type) {
|
|
77
|
+
case "number":
|
|
78
|
+
case "money":
|
|
79
|
+
return "DOUBLE PRECISION";
|
|
80
|
+
case "boolean":
|
|
81
|
+
return "BOOLEAN";
|
|
82
|
+
case "relation":
|
|
83
|
+
return "INTEGER";
|
|
84
|
+
default:
|
|
85
|
+
return "TEXT";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function mySqlType(field: FieldDef): string {
|
|
90
|
+
switch (field.type) {
|
|
91
|
+
case "number":
|
|
92
|
+
case "money":
|
|
93
|
+
return "DOUBLE";
|
|
94
|
+
case "boolean":
|
|
95
|
+
return "TINYINT(1)";
|
|
96
|
+
case "relation":
|
|
97
|
+
return "INT";
|
|
98
|
+
default:
|
|
99
|
+
return "TEXT";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { sqlType };
|
|
104
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type Handler = (payload: unknown) => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
const handlers = new Map<string, Handler[]>();
|
|
4
|
+
|
|
5
|
+
export function on(event: string, handler: Handler): void {
|
|
6
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
7
|
+
handlers.get(event)!.push(handler);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function emit(event: string, payload: unknown): Promise<void> {
|
|
11
|
+
const list = handlers.get(event) ?? [];
|
|
12
|
+
for (const fn of list) await fn(payload);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clearEvents(): void {
|
|
16
|
+
handlers.clear();
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function toCsv(rows: Record<string, unknown>[]): string {
|
|
2
|
+
if (rows.length === 0) return "";
|
|
3
|
+
const keys = Object.keys(rows[0]);
|
|
4
|
+
const escape = (v: unknown) => {
|
|
5
|
+
const s = v == null ? "" : String(v);
|
|
6
|
+
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
7
|
+
return s;
|
|
8
|
+
};
|
|
9
|
+
const lines = [keys.join(",")];
|
|
10
|
+
for (const row of rows) {
|
|
11
|
+
lines.push(keys.map((k) => escape(row[k])).join(","));
|
|
12
|
+
}
|
|
13
|
+
return lines.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function exportResponse(
|
|
17
|
+
rows: Record<string, unknown>[],
|
|
18
|
+
format: "csv" | "json",
|
|
19
|
+
filename: string
|
|
20
|
+
): Response {
|
|
21
|
+
if (format === "json") {
|
|
22
|
+
return new Response(JSON.stringify({ data: rows }, null, 2), {
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"Content-Disposition": `attachment; filename="${filename}.json"`,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const csv = toCsv(rows);
|
|
30
|
+
return new Response(csv, {
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
33
|
+
"Content-Disposition": `attachment; filename="${filename}.csv"`,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { config } from "../../core/src/config.js";
|
|
4
|
+
|
|
5
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
6
|
+
|
|
7
|
+
const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
8
|
+
|
|
9
|
+
function logDir(): string {
|
|
10
|
+
const dir = join(process.cwd(), "storage", "logs");
|
|
11
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function shouldLog(level: LogLevel): boolean {
|
|
16
|
+
const min = (config("LOG_LEVEL") || "debug") as LogLevel;
|
|
17
|
+
return LEVELS[level] >= (LEVELS[min] ?? 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function write(level: LogLevel, message: string, extra?: unknown): void {
|
|
21
|
+
if (!shouldLog(level)) return;
|
|
22
|
+
const line = `[${new Date().toISOString()}] ${level.toUpperCase()}: ${message}${
|
|
23
|
+
extra !== undefined ? " " + JSON.stringify(extra) : ""
|
|
24
|
+
}\n`;
|
|
25
|
+
process.stdout.write(line);
|
|
26
|
+
try {
|
|
27
|
+
appendFileSync(join(logDir(), "nexa.log"), line);
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore file errors */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const log = {
|
|
34
|
+
debug: (msg: string, extra?: unknown) => write("debug", msg, extra),
|
|
35
|
+
info: (msg: string, extra?: unknown) => write("info", msg, extra),
|
|
36
|
+
warn: (msg: string, extra?: unknown) => write("warn", msg, extra),
|
|
37
|
+
error: (msg: string, extra?: unknown) => write("error", msg, extra),
|
|
38
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { config } from "../../core/src/config.js";
|
|
4
|
+
import { log } from "../../log/src/index.js";
|
|
5
|
+
import type { Database } from "../../database/src/database.js";
|
|
6
|
+
import { dispatch } from "../../queue/src/index.js";
|
|
7
|
+
|
|
8
|
+
export interface MailMessage {
|
|
9
|
+
to: string | string[];
|
|
10
|
+
subject: string;
|
|
11
|
+
html?: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
from?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class MailBuilder {
|
|
17
|
+
private _to: string[] = [];
|
|
18
|
+
private _subject = "";
|
|
19
|
+
private _html = "";
|
|
20
|
+
private _text = "";
|
|
21
|
+
private _from?: string;
|
|
22
|
+
|
|
23
|
+
to(address: string | string[]): this {
|
|
24
|
+
this._to = Array.isArray(address) ? address : [address];
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
subject(s: string): this {
|
|
29
|
+
this._subject = s;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
html(body: string): this {
|
|
34
|
+
this._html = body;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
text(body: string): this {
|
|
39
|
+
this._text = body;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
from(address: string): this {
|
|
44
|
+
this._from = address;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async send(): Promise<void> {
|
|
49
|
+
await sendMail({
|
|
50
|
+
to: this._to,
|
|
51
|
+
subject: this._subject,
|
|
52
|
+
html: this._html || undefined,
|
|
53
|
+
text: this._text || undefined,
|
|
54
|
+
from: this._from,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async queue(db: Database): Promise<number> {
|
|
59
|
+
return dispatch(db, "mail.send", {
|
|
60
|
+
to: this._to,
|
|
61
|
+
subject: this._subject,
|
|
62
|
+
html: this._html || undefined,
|
|
63
|
+
text: this._text || undefined,
|
|
64
|
+
from: this._from,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Mail(): MailBuilder {
|
|
70
|
+
return new MailBuilder();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function mail(
|
|
74
|
+
to: string | string[],
|
|
75
|
+
subject: string,
|
|
76
|
+
body: string,
|
|
77
|
+
opts?: { html?: boolean; queue?: Database }
|
|
78
|
+
): Promise<void | number> {
|
|
79
|
+
const builder = Mail().to(to).subject(subject);
|
|
80
|
+
if (opts?.html) builder.html(body);
|
|
81
|
+
else builder.text(body);
|
|
82
|
+
|
|
83
|
+
if (opts?.queue) return builder.queue(opts.queue);
|
|
84
|
+
await builder.send();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function sendMail(msg: MailMessage): Promise<void> {
|
|
88
|
+
const driver = config("MAIL_DRIVER") || "log";
|
|
89
|
+
const from = msg.from || config("MAIL_FROM") || "nexa@localhost";
|
|
90
|
+
const to = Array.isArray(msg.to) ? msg.to.join(", ") : msg.to;
|
|
91
|
+
|
|
92
|
+
if (driver === "log") {
|
|
93
|
+
const dir = join(process.cwd(), "storage", "logs");
|
|
94
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
95
|
+
const line = `[${new Date().toISOString()}] MAIL to=${to} subject=${msg.subject}\n${msg.html || msg.text || ""}\n---\n`;
|
|
96
|
+
appendFileSync(join(dir, "mail.log"), line);
|
|
97
|
+
log.info(`Mail (log) → ${to}: ${msg.subject}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (driver === "smtp") {
|
|
102
|
+
const nodemailer = await import("nodemailer");
|
|
103
|
+
const transport = nodemailer.createTransport({
|
|
104
|
+
host: config("MAIL_HOST") || "localhost",
|
|
105
|
+
port: Number(config("MAIL_PORT") || 587),
|
|
106
|
+
secure: config("MAIL_SECURE") === "true",
|
|
107
|
+
auth:
|
|
108
|
+
config("MAIL_USER")
|
|
109
|
+
? { user: config("MAIL_USER"), pass: config("MAIL_PASS") }
|
|
110
|
+
: undefined,
|
|
111
|
+
});
|
|
112
|
+
await transport.sendMail({
|
|
113
|
+
from,
|
|
114
|
+
to,
|
|
115
|
+
subject: msg.subject,
|
|
116
|
+
html: msg.html,
|
|
117
|
+
text: msg.text,
|
|
118
|
+
});
|
|
119
|
+
log.info(`Mail (smtp) → ${to}: ${msg.subject}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error(`Unknown MAIL_DRIVER: ${driver}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function registerMailJobs(): void {
|
|
127
|
+
// wired via queue emit handler in app bootstrap
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { sendMail };
|