veko-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/Readme.md +233 -0
- package/bin/veko.js +79 -0
- package/index.js +94 -0
- package/package.json +71 -0
- package/src/cli/build.js +16 -0
- package/src/cli/check.ts +203 -0
- package/src/cli/dev.ts +189 -0
- package/src/cli/migrate.js +80 -0
- package/src/compiler/aot-validation.js +67 -0
- package/src/compiler/codegen.js +203 -0
- package/src/compiler/compile.d.ts +16 -0
- package/src/compiler/compile.js +39 -0
- package/src/compiler/index.js +5 -0
- package/src/compiler/lexer.js +114 -0
- package/src/compiler/parser.d.ts +7 -0
- package/src/compiler/parser.js +185 -0
- package/src/compiler/resolver.d.ts +10 -0
- package/src/compiler/resolver.js +88 -0
- package/src/compiler/source-utils.js +46 -0
- package/src/compiler/types.d.ts +50 -0
- package/src/compiler/types.js +15 -0
- package/src/config/config-types.js +35 -0
- package/src/config/defaults.d.ts +16 -0
- package/src/config/defaults.js +36 -0
- package/src/config/index.js +6 -0
- package/src/config/load-config.js +59 -0
- package/src/runtime/adapters/base.js +96 -0
- package/src/runtime/adapters/postgres.js +98 -0
- package/src/runtime/adapters/sqlite.js +87 -0
- package/src/runtime/auth.js +64 -0
- package/src/runtime/errors.js +63 -0
- package/src/runtime/index.js +36 -0
- package/src/runtime/queryBuilder.js +86 -0
- package/src/runtime/sugar.js +76 -0
- package/src/runtime/validator.js +71 -0
- package/src/runtime/wrapAction.js +24 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – DatabaseAdapter interface (SOLID)
|
|
3
|
+
* All adapters (sqlite, postgres) must implement this contract.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {'asc'|'desc'} SortDirection
|
|
8
|
+
* @typedef {{ field: string, direction: SortDirection }} OrderBy
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base adapter: defines the interface. Subclasses implement all methods.
|
|
13
|
+
* @abstract
|
|
14
|
+
*/
|
|
15
|
+
export class DatabaseAdapter {
|
|
16
|
+
/**
|
|
17
|
+
* Connect to the database. Must be called before any other method.
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
async connect() {
|
|
21
|
+
throw new Error('DatabaseAdapter.connect() must be implemented');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run a raw query (SELECT only in safe sugar context). Returns rows.
|
|
26
|
+
* @param {string} sql
|
|
27
|
+
* @param {unknown[]} [params]
|
|
28
|
+
* @returns {Promise<unknown[]>}
|
|
29
|
+
*/
|
|
30
|
+
async query(sql, params = []) {
|
|
31
|
+
throw new Error('DatabaseAdapter.query() must be implemented');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Insert a row. Returns { id, changes }.
|
|
36
|
+
* @param {string} table
|
|
37
|
+
* @param {Record<string, unknown>} data
|
|
38
|
+
* @returns {Promise<{ id: number, changes?: number }>}
|
|
39
|
+
*/
|
|
40
|
+
async insert(table, data) {
|
|
41
|
+
throw new Error('DatabaseAdapter.insert() must be implemented');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Update row by id. Returns { updated: boolean }.
|
|
46
|
+
* @param {string} table
|
|
47
|
+
* @param {number} id
|
|
48
|
+
* @param {Record<string, unknown>} data
|
|
49
|
+
* @returns {Promise<{ updated: boolean }>}
|
|
50
|
+
*/
|
|
51
|
+
async update(table, id, data) {
|
|
52
|
+
throw new Error('DatabaseAdapter.update() must be implemented');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete row by id. Returns { deleted: boolean }.
|
|
57
|
+
* @param {string} table
|
|
58
|
+
* @param {number} id
|
|
59
|
+
* @returns {Promise<{ deleted: boolean }>}
|
|
60
|
+
*/
|
|
61
|
+
async delete(table, id) {
|
|
62
|
+
throw new Error('DatabaseAdapter.delete() must be implemented');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find one row by id. Returns row or null.
|
|
67
|
+
* @param {string} table
|
|
68
|
+
* @param {number} id
|
|
69
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
70
|
+
*/
|
|
71
|
+
async findById(table, id) {
|
|
72
|
+
throw new Error('DatabaseAdapter.findById() must be implemented');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* List rows with optional where and orderBy.
|
|
77
|
+
* @param {string} table
|
|
78
|
+
* @param {Record<string, unknown>} [where]
|
|
79
|
+
* @param {OrderBy|null} [orderBy]
|
|
80
|
+
* @returns {Promise<unknown[]>}
|
|
81
|
+
*/
|
|
82
|
+
async findAll(table, where = {}, orderBy = null) {
|
|
83
|
+
throw new Error('DatabaseAdapter.findAll() must be implemented');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run a callback inside a transaction.
|
|
88
|
+
* @param {(adapter: DatabaseAdapter) => Promise<unknown>} callback
|
|
89
|
+
* @returns {Promise<unknown>}
|
|
90
|
+
*/
|
|
91
|
+
async transaction(callback) {
|
|
92
|
+
throw new Error('DatabaseAdapter.transaction() must be implemented');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default DatabaseAdapter;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – PostgreSQL adapter (implements DatabaseAdapter)
|
|
3
|
+
* Requires: pg package
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DatabaseAdapter } from './base.js';
|
|
7
|
+
|
|
8
|
+
export class PostgresAdapter extends DatabaseAdapter {
|
|
9
|
+
constructor(connectionString) {
|
|
10
|
+
super();
|
|
11
|
+
this.connectionString = connectionString;
|
|
12
|
+
this.pool = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async connect() {
|
|
16
|
+
const { default: pg } = await import('pg');
|
|
17
|
+
this.pool = new pg.Pool({ connectionString: this.connectionString });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async _query(sql, params = []) {
|
|
21
|
+
const client = await this.pool.connect();
|
|
22
|
+
try {
|
|
23
|
+
const result = await client.query(sql, params);
|
|
24
|
+
return result.rows;
|
|
25
|
+
} finally {
|
|
26
|
+
client.release();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async query(sql, params = []) {
|
|
31
|
+
const client = await this.pool.connect();
|
|
32
|
+
try {
|
|
33
|
+
const result = await client.query(sql, params);
|
|
34
|
+
return result.rows;
|
|
35
|
+
} finally {
|
|
36
|
+
client.release();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async insert(table, data) {
|
|
41
|
+
const keys = Object.keys(data);
|
|
42
|
+
const cols = keys.map((k) => `"${k}"`).join(', ');
|
|
43
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
|
|
44
|
+
const sql = `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) RETURNING id`;
|
|
45
|
+
const rows = await this._query(sql, Object.values(data));
|
|
46
|
+
return { id: rows[0]?.id, changes: 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async update(table, id, data) {
|
|
50
|
+
const keys = Object.keys(data);
|
|
51
|
+
if (keys.length === 0) return { updated: false };
|
|
52
|
+
const sets = keys.map((k, i) => `"${k}" = $${i + 1}`).join(', ');
|
|
53
|
+
const sql = `UPDATE "${table}" SET ${sets} WHERE id = $${keys.length + 1}`;
|
|
54
|
+
const result = await this.pool.query(sql, [...Object.values(data), id]);
|
|
55
|
+
return { updated: (result.rowCount ?? 0) > 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(table, id) {
|
|
59
|
+
const result = await this.pool.query(`DELETE FROM "${table}" WHERE id = $1`, [id]);
|
|
60
|
+
return { deleted: (result.rowCount ?? 0) > 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async findById(table, id) {
|
|
64
|
+
const rows = await this._query(`SELECT * FROM "${table}" WHERE id = $1`, [id]);
|
|
65
|
+
return rows[0] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async findAll(table, where = {}, orderBy = null) {
|
|
69
|
+
let sql = `SELECT * FROM "${table}"`;
|
|
70
|
+
const params = [];
|
|
71
|
+
let n = 1;
|
|
72
|
+
if (Object.keys(where).length > 0) {
|
|
73
|
+
const conditions = Object.keys(where).map((k) => `"${k}" = $${n++}`);
|
|
74
|
+
params.push(...Object.values(where));
|
|
75
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
76
|
+
}
|
|
77
|
+
if (orderBy) {
|
|
78
|
+
const dir = orderBy.direction === 'desc' ? 'DESC' : 'ASC';
|
|
79
|
+
sql += ` ORDER BY "${orderBy.field}" ${dir}`;
|
|
80
|
+
}
|
|
81
|
+
return this._query(sql, params);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async transaction(callback) {
|
|
85
|
+
const client = await this.pool.connect();
|
|
86
|
+
try {
|
|
87
|
+
await client.query('BEGIN');
|
|
88
|
+
const result = await callback(this);
|
|
89
|
+
await client.query('COMMIT');
|
|
90
|
+
return result;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
await client.query('ROLLBACK');
|
|
93
|
+
throw e;
|
|
94
|
+
} finally {
|
|
95
|
+
client.release();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – SQLite adapter (implements DatabaseAdapter)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { DatabaseAdapter } from './base.js';
|
|
7
|
+
|
|
8
|
+
export class SqliteAdapter extends DatabaseAdapter {
|
|
9
|
+
constructor(path = 'veko.db') {
|
|
10
|
+
super();
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.db = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async connect() {
|
|
16
|
+
this.db = new Database(this.path);
|
|
17
|
+
this.db.pragma('journal_mode = WAL');
|
|
18
|
+
this.db.pragma('busy_timeout = 5000');
|
|
19
|
+
this.db.pragma('foreign_keys = ON');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async query(sql, params = []) {
|
|
23
|
+
const stmt = this.db.prepare(sql);
|
|
24
|
+
return stmt.all(...params);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async insert(table, data) {
|
|
28
|
+
const keys = Object.keys(data);
|
|
29
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
30
|
+
const sql = `INSERT INTO "${table}" (${keys.map((k) => `"${k}"`).join(', ')}) VALUES (${placeholders})`;
|
|
31
|
+
const stmt = this.db.prepare(sql);
|
|
32
|
+
const result = stmt.run(...Object.values(data));
|
|
33
|
+
return { id: result.lastInsertRowid, changes: result.changes };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async update(table, id, data) {
|
|
37
|
+
const keys = Object.keys(data);
|
|
38
|
+
if (keys.length === 0) return { updated: false };
|
|
39
|
+
const sets = keys.map((k) => `"${k}" = ?`).join(', ');
|
|
40
|
+
const sql = `UPDATE "${table}" SET ${sets} WHERE id = ?`;
|
|
41
|
+
const stmt = this.db.prepare(sql);
|
|
42
|
+
const result = stmt.run(...Object.values(data), id);
|
|
43
|
+
return { updated: result.changes > 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async delete(table, id) {
|
|
47
|
+
const sql = `DELETE FROM "${table}" WHERE id = ?`;
|
|
48
|
+
const stmt = this.db.prepare(sql);
|
|
49
|
+
const result = stmt.run(id);
|
|
50
|
+
return { deleted: result.changes > 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async findById(table, id) {
|
|
54
|
+
const sql = `SELECT * FROM "${table}" WHERE id = ?`;
|
|
55
|
+
const stmt = this.db.prepare(sql);
|
|
56
|
+
return stmt.get(id) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Safe list: orderBy must be { field: string, direction: 'asc'|'desc' } and field allowed by schema.
|
|
61
|
+
* @param {string} table
|
|
62
|
+
* @param {object} [where]
|
|
63
|
+
* @param {{ field: string, direction: 'asc'|'desc' }} [orderBy]
|
|
64
|
+
*/
|
|
65
|
+
async findAll(table, where = {}, orderBy = null) {
|
|
66
|
+
let sql = `SELECT * FROM "${table}"`;
|
|
67
|
+
const params = [];
|
|
68
|
+
if (Object.keys(where).length > 0) {
|
|
69
|
+
const conditions = Object.keys(where).map((k) => `"${k}" = ?`);
|
|
70
|
+
params.push(...Object.values(where));
|
|
71
|
+
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
72
|
+
}
|
|
73
|
+
if (orderBy) {
|
|
74
|
+
const dir = orderBy.direction === 'desc' ? 'DESC' : 'ASC';
|
|
75
|
+
sql += ` ORDER BY "${orderBy.field}" ${dir}`;
|
|
76
|
+
}
|
|
77
|
+
const stmt = this.db.prepare(sql);
|
|
78
|
+
return stmt.all(...params);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async transaction(callback) {
|
|
82
|
+
const run = this.db.transaction(() => {
|
|
83
|
+
return callback(this);
|
|
84
|
+
});
|
|
85
|
+
return run();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – Auth (JWT Bearer middleware + sign helper for login)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
6
|
+
|
|
7
|
+
const DEV_SECRET = 'changeme-secret';
|
|
8
|
+
|
|
9
|
+
function getDefaultSecret() {
|
|
10
|
+
const secret = process.env.JWT_SECRET || DEV_SECRET;
|
|
11
|
+
if (process.env.NODE_ENV === 'production' && (secret === DEV_SECRET || !process.env.JWT_SECRET)) {
|
|
12
|
+
throw new Error('JWT_SECRET must be set in production. Do not use the default secret.');
|
|
13
|
+
}
|
|
14
|
+
return secret;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Lazy so we only enforce in production when auth is actually used. */
|
|
18
|
+
function defaultSecret() {
|
|
19
|
+
return getDefaultSecret();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sign a JWT payload (e.g. for login). Uses JWT_SECRET.
|
|
24
|
+
* @param {object} payload - Claims (e.g. { sub: userId, username })
|
|
25
|
+
* @param {{ expiresIn?: string }} [opts] - Optional expiresIn (default '1h')
|
|
26
|
+
*/
|
|
27
|
+
export function signToken(payload, opts = {}) {
|
|
28
|
+
return jwt.sign(
|
|
29
|
+
payload,
|
|
30
|
+
defaultSecret(),
|
|
31
|
+
{ algorithm: 'HS256', expiresIn: opts.expiresIn ?? '1h' }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{ secret?: string }} [options]
|
|
37
|
+
*/
|
|
38
|
+
export function createAuth(options = {}) {
|
|
39
|
+
const secret = options.secret ?? defaultSecret();
|
|
40
|
+
|
|
41
|
+
function authMiddleware(req, res, next) {
|
|
42
|
+
const header = req.headers.authorization;
|
|
43
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
44
|
+
return res.status(401).json({ error: 'Unauthorized', message: 'Missing or invalid Authorization header' });
|
|
45
|
+
}
|
|
46
|
+
const token = header.slice(7);
|
|
47
|
+
try {
|
|
48
|
+
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
|
|
49
|
+
req.user = decoded;
|
|
50
|
+
next();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return res.status(401).json({
|
|
53
|
+
error: 'Unauthorized',
|
|
54
|
+
message: err instanceof Error ? err.message : String(err),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { authMiddleware };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default export for generated code: single middleware
|
|
63
|
+
const { authMiddleware } = createAuth();
|
|
64
|
+
export { authMiddleware };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – Consistent error type for API and DB errors
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class VekoError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @param {number} [status=500]
|
|
9
|
+
*/
|
|
10
|
+
constructor(message, status = 500) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'VekoError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
if (typeof Error.captureStackTrace === 'function') {
|
|
15
|
+
Error.captureStackTrace(this, VekoError);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize any thrown value to a VekoError. Preserves status if present.
|
|
22
|
+
* Maps common DB errors to user-friendly messages and status codes.
|
|
23
|
+
* @param {unknown} err
|
|
24
|
+
* @returns {VekoError}
|
|
25
|
+
*/
|
|
26
|
+
export function toVekoError(err) {
|
|
27
|
+
if (err instanceof VekoError) return err;
|
|
28
|
+
const status = err && typeof err === 'object' && 'status' in err ? Number(err.status) : undefined;
|
|
29
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
30
|
+
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
|
|
31
|
+
|
|
32
|
+
let finalStatus = status;
|
|
33
|
+
let finalMessage = message;
|
|
34
|
+
|
|
35
|
+
if (!finalStatus) {
|
|
36
|
+
if (err && typeof err === 'object' && 'type' in err && err.type === 'entity.parse.failed') {
|
|
37
|
+
finalStatus = 400;
|
|
38
|
+
finalMessage = 'Invalid JSON body';
|
|
39
|
+
} else if (code === 'SQLITE_CONSTRAINT_UNIQUE' || code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || code === '23505') {
|
|
40
|
+
finalStatus = 409;
|
|
41
|
+
finalMessage = 'Resource already exists';
|
|
42
|
+
} else if (code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || code === '23503') {
|
|
43
|
+
finalStatus = 400;
|
|
44
|
+
finalMessage = 'Invalid reference';
|
|
45
|
+
} else if (code === 'SQLITE_CONSTRAINT_NOTNULL' || code === '23502') {
|
|
46
|
+
finalStatus = 400;
|
|
47
|
+
finalMessage = message || 'Required value missing';
|
|
48
|
+
} else if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
|
|
49
|
+
finalStatus = 503;
|
|
50
|
+
finalMessage = 'Database busy, please retry';
|
|
51
|
+
} else if (message && (message.includes('ECONNREFUSED') || message.includes('ENOENT'))) {
|
|
52
|
+
finalStatus = 503;
|
|
53
|
+
finalMessage = 'Service temporarily unavailable';
|
|
54
|
+
} else {
|
|
55
|
+
finalStatus = 500;
|
|
56
|
+
finalMessage = message || 'Internal server error';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new VekoError(finalMessage, finalStatus);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default VekoError;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – Plugin interface (extensible without changing core)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} VekoPlugin
|
|
7
|
+
* @property {function(object): void} [onRouteRegister] – called when a route is registered
|
|
8
|
+
* @property {function(object): Promise<object>|object} [beforeAction] – run before action; can mutate ctx
|
|
9
|
+
* @property {function(any): Promise<any>|any} [afterAction] – run after action; can transform result
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run beforeAction hooks in order
|
|
14
|
+
* @param {VekoPlugin[]} plugins
|
|
15
|
+
* @param {object} ctx
|
|
16
|
+
*/
|
|
17
|
+
export async function runBeforeAction(plugins, ctx) {
|
|
18
|
+
let current = ctx;
|
|
19
|
+
for (const p of plugins) {
|
|
20
|
+
if (p.beforeAction) current = await Promise.resolve(p.beforeAction(current)) ?? current;
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run afterAction hooks in order
|
|
27
|
+
* @param {VekoPlugin[]} plugins
|
|
28
|
+
* @param {any} result
|
|
29
|
+
*/
|
|
30
|
+
export async function runAfterAction(plugins, result) {
|
|
31
|
+
let current = result;
|
|
32
|
+
for (const p of plugins) {
|
|
33
|
+
if (p.afterAction) current = await Promise.resolve(p.afterAction(current)) ?? current;
|
|
34
|
+
}
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – QueryBuilder: SQL preparation logic for AOT-generated server
|
|
3
|
+
* Single place for INSERT/UPDATE/DELETE/SELECT by id. Generated code calls prepareAll() at startup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build INSERT SQL for a table.
|
|
8
|
+
* @param {string} table
|
|
9
|
+
* @param {string[]} columns
|
|
10
|
+
* @param {'sqlite'|'postgres'} adapter
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function insertSql(table, columns, adapter) {
|
|
14
|
+
const cols = columns.map((c) => `"${c}"`).join(', ');
|
|
15
|
+
const params = adapter === 'sqlite' ? columns.map(() => '?').join(', ') : columns.map((_, i) => `$${i + 1}`).join(', ');
|
|
16
|
+
const returning = adapter === 'postgres' ? ' RETURNING id' : '';
|
|
17
|
+
return `INSERT INTO "${table}" (${cols}) VALUES (${params})${returning}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build UPDATE SQL for a table.
|
|
22
|
+
* @param {string} table
|
|
23
|
+
* @param {string[]} columns
|
|
24
|
+
* @param {'sqlite'|'postgres'} adapter
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function updateSql(table, columns, adapter) {
|
|
28
|
+
if (columns.length === 0) return `UPDATE "${table}" SET id = id WHERE id = ?`;
|
|
29
|
+
const sets = adapter === 'sqlite'
|
|
30
|
+
? columns.map((c) => `"${c}" = ?`).join(', ')
|
|
31
|
+
: columns.map((c, i) => `"${c}" = $${i + 1}`).join(', ');
|
|
32
|
+
const idParam = adapter === 'sqlite' ? '?' : `$${columns.length + 1}`;
|
|
33
|
+
return `UPDATE "${table}" SET ${sets} WHERE id = ${idParam}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build DELETE SQL for a table.
|
|
38
|
+
* @param {string} table
|
|
39
|
+
* @param {'sqlite'|'postgres'} adapter
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function deleteSql(table, adapter) {
|
|
43
|
+
return adapter === 'sqlite' ? `DELETE FROM "${table}" WHERE id = ?` : `DELETE FROM "${table}" WHERE id = $1`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build SELECT by id SQL for a table.
|
|
48
|
+
* @param {string} table
|
|
49
|
+
* @param {'sqlite'|'postgres'} adapter
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function findByIdSql(table, adapter) {
|
|
53
|
+
return adapter === 'sqlite' ? `SELECT * FROM "${table}" WHERE id = ?` : `SELECT * FROM "${table}" WHERE id = $1`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the PREP object used by AOT db helpers. Called once at server startup.
|
|
58
|
+
* @param {'sqlite'|'postgres'} adapterKind
|
|
59
|
+
* @param {import('./adapters/base.js').DatabaseAdapter} db - Connected adapter instance
|
|
60
|
+
* @param {Record<string, string[]>} tableCols - { TableName: ['col1','col2'], ... }
|
|
61
|
+
* @returns {Record<string, { insert: unknown, update: unknown, delete: unknown, findById: unknown }>}
|
|
62
|
+
*/
|
|
63
|
+
export function prepareAll(adapterKind, db, tableCols) {
|
|
64
|
+
const PREP = {};
|
|
65
|
+
for (const [table, columns] of Object.entries(tableCols)) {
|
|
66
|
+
if (adapterKind === 'sqlite') {
|
|
67
|
+
const raw = /** @type {import('./adapters/sqlite.js').SqliteAdapter} */ (db).db;
|
|
68
|
+
PREP[table] = {
|
|
69
|
+
insert: raw.prepare(insertSql(table, columns, 'sqlite')),
|
|
70
|
+
update: raw.prepare(updateSql(table, columns, 'sqlite')),
|
|
71
|
+
delete: raw.prepare(deleteSql(table, 'sqlite')),
|
|
72
|
+
findById: raw.prepare(findByIdSql(table, 'sqlite')),
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
PREP[table] = {
|
|
76
|
+
insert: { name: `${table}_insert`, text: insertSql(table, columns, 'postgres') },
|
|
77
|
+
update: { name: `${table}_update`, text: updateSql(table, columns, 'postgres') },
|
|
78
|
+
delete: { name: `${table}_delete`, text: deleteSql(table, 'postgres') },
|
|
79
|
+
findById: { name: `${table}_findById`, text: findByIdSql(table, 'postgres') },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return PREP;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default { insertSql, updateSql, deleteSql, findByIdSql, prepareAll };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – Sugar layer (safe table allowlist + safe orderBy + SELECT-only query)
|
|
3
|
+
* ORDER BY field must be in table schema (passed from compiler).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ALLOWED_DIRECTIONS = new Set(['asc', 'desc']);
|
|
7
|
+
|
|
8
|
+
/** Allow only SELECT to prevent accidental or malicious writes via sugar.query. */
|
|
9
|
+
function assertSelectOnly(sql) {
|
|
10
|
+
const trimmed = (typeof sql === 'string' ? sql : '').trim();
|
|
11
|
+
if (!trimmed.toUpperCase().startsWith('SELECT')) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
'sugar.query() is restricted to SELECT statements only. Use sugar.save/update/remove for writes.'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('./adapters/sqlite.js').SqliteAdapter|import('./adapters/postgres.js').PostgresAdapter} db
|
|
20
|
+
* @param {Set<string>} knownTables
|
|
21
|
+
* @param {Record<string, string[]>} [tableColumns] - Optional map of table name → allowed column names (e.g. from compiler). Includes 'id'.
|
|
22
|
+
*/
|
|
23
|
+
export function createSugar(db, knownTables, tableColumns = {}) {
|
|
24
|
+
function assertTable(table) {
|
|
25
|
+
if (!knownTables.has(table)) {
|
|
26
|
+
throw new Error(`sugar: unknown table "${table}". Allowed: ${[...knownTables].join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertOrderBy(table, orderBy) {
|
|
31
|
+
if (!orderBy || typeof orderBy !== 'object') return;
|
|
32
|
+
const { field, direction } = orderBy;
|
|
33
|
+
if (!field || typeof field !== 'string') throw new Error('orderBy.field must be a string');
|
|
34
|
+
if (!ALLOWED_DIRECTIONS.has((direction || '').toLowerCase())) {
|
|
35
|
+
throw new Error('orderBy.direction must be "asc" or "desc"');
|
|
36
|
+
}
|
|
37
|
+
assertTable(table);
|
|
38
|
+
const allowed = tableColumns[table];
|
|
39
|
+
if (allowed && !allowed.includes(field)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`sugar: orderBy.field "${field}" is not a valid column for table "${table}". Allowed: ${allowed.join(', ')}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async save(table, data) {
|
|
48
|
+
assertTable(table);
|
|
49
|
+
return db.insert(table, data);
|
|
50
|
+
},
|
|
51
|
+
async all(table, where = {}, orderBy = null) {
|
|
52
|
+
assertTable(table);
|
|
53
|
+
assertOrderBy(table, orderBy);
|
|
54
|
+
return db.findAll(table, where, orderBy ? { field: orderBy.field, direction: (orderBy.direction || 'asc').toLowerCase() } : null);
|
|
55
|
+
},
|
|
56
|
+
async find(table, id) {
|
|
57
|
+
assertTable(table);
|
|
58
|
+
return db.findById(table, id);
|
|
59
|
+
},
|
|
60
|
+
async remove(table, id) {
|
|
61
|
+
assertTable(table);
|
|
62
|
+
return db.delete(table, id);
|
|
63
|
+
},
|
|
64
|
+
async update(table, id, data) {
|
|
65
|
+
assertTable(table);
|
|
66
|
+
return db.update(table, id, data);
|
|
67
|
+
},
|
|
68
|
+
async query(sql, ...params) {
|
|
69
|
+
assertSelectOnly(sql);
|
|
70
|
+
return db.query(sql, params);
|
|
71
|
+
},
|
|
72
|
+
async transaction(callback) {
|
|
73
|
+
return db.transaction(callback);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veko v1 – Validator (schema def → middleware + validate function)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} tableName
|
|
7
|
+
* @param {Record<string,{ type: string, required?: boolean, min?: number, max?: number, enum?: string[] }>} schema
|
|
8
|
+
*/
|
|
9
|
+
export function createValidator(tableName, schema) {
|
|
10
|
+
function validate(data) {
|
|
11
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
12
|
+
const err = new Error(`${tableName}: body must be a plain object`);
|
|
13
|
+
err.status = 400;
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
const result = {};
|
|
17
|
+
for (const [key, opts] of Object.entries(schema)) {
|
|
18
|
+
const value = data[key];
|
|
19
|
+
if (value === undefined || value === null) {
|
|
20
|
+
if (opts.required !== false) {
|
|
21
|
+
const err = new Error(`${tableName}: missing required field "${key}"`);
|
|
22
|
+
err.status = 400;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
result[key] = null;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (opts.type === 'string' && typeof value !== 'string') {
|
|
29
|
+
const err = new Error(`${tableName}: "${key}" must be a string`);
|
|
30
|
+
err.status = 400;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (opts.type === 'number') {
|
|
34
|
+
const n = Number(value);
|
|
35
|
+
if (!Number.isFinite(n)) {
|
|
36
|
+
const err = new Error(`${tableName}: "${key}" must be a number`);
|
|
37
|
+
err.status = 400;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
if (opts.min !== undefined && n < opts.min) throw Object.assign(new Error(`${tableName}: "${key}" must be >= ${opts.min}`), { status: 400 });
|
|
41
|
+
if (opts.max !== undefined && n > opts.max) throw Object.assign(new Error(`${tableName}: "${key}" must be <= ${opts.max}`), { status: 400 });
|
|
42
|
+
result[key] = n;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (opts.type === 'string' || opts.type === 'enum') {
|
|
46
|
+
if (typeof value !== 'string') throw Object.assign(new Error(`${tableName}: "${key}" must be a string`), { status: 400 });
|
|
47
|
+
if (opts.min !== undefined && value.length < opts.min) throw Object.assign(new Error(`${tableName}: "${key}" too short`), { status: 400 });
|
|
48
|
+
if (opts.max !== undefined && value.length > opts.max) throw Object.assign(new Error(`${tableName}: "${key}" too long`), { status: 400 });
|
|
49
|
+
if (opts.enum && !opts.enum.includes(value)) throw Object.assign(new Error(`${tableName}: "${key}" must be one of ${opts.enum.join(', ')}`), { status: 400 });
|
|
50
|
+
result[key] = value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
result[key] = value;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Express middleware (valid handler, not a factory). */
|
|
59
|
+
function middleware(req, res, next) {
|
|
60
|
+
try {
|
|
61
|
+
req.validated = validate(req.body ?? {});
|
|
62
|
+
next();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const status = err?.status ?? 400;
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
res.status(status).json({ error: message, message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { validate, middleware };
|
|
71
|
+
}
|