rotifex 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.
- package/LICENSE +21 -0
- package/README.md +1958 -0
- package/bin/rotifex.js +18 -0
- package/config.default.json +29 -0
- package/package.json +48 -0
- package/src/ai/agent.routes.js +110 -0
- package/src/ai/agent.service.js +326 -0
- package/src/ai/agents.config.js +66 -0
- package/src/ai/ai.config.js +84 -0
- package/src/ai/ai.routes.js +149 -0
- package/src/ai/ai.service.js +275 -0
- package/src/ai/ai.usage.js +58 -0
- package/src/ai/tools/registry.js +156 -0
- package/src/auth/auth.controller.js +118 -0
- package/src/auth/auth.routes.js +27 -0
- package/src/auth/auth.service.js +182 -0
- package/src/auth/jwt.middleware.js +41 -0
- package/src/auth/password.util.js +6 -0
- package/src/commands/init.js +30 -0
- package/src/commands/migrate.js +62 -0
- package/src/commands/start.js +96 -0
- package/src/db/adapters/base.js +73 -0
- package/src/db/adapters/sqlite.js +86 -0
- package/src/db/connection.js +45 -0
- package/src/db/index.js +12 -0
- package/src/db/migrator.js +142 -0
- package/src/db/schema.js +48 -0
- package/src/engine/index.js +76 -0
- package/src/engine/queryBuilder.js +52 -0
- package/src/engine/routeFactory.js +119 -0
- package/src/engine/schemaLoader.js +75 -0
- package/src/engine/schemaStore.js +35 -0
- package/src/engine/tableSync.js +28 -0
- package/src/engine/zodFactory.js +54 -0
- package/src/lib/config.js +120 -0
- package/src/lib/logBuffer.js +75 -0
- package/src/lib/logger.js +26 -0
- package/src/server/index.js +8 -0
- package/src/server/middleware/errorHandler.js +26 -0
- package/src/server/middleware/requestLogger.js +23 -0
- package/src/server/plugins.js +38 -0
- package/src/server/routes/admin.js +276 -0
- package/src/server/routes/files.js +188 -0
- package/src/server/routes/health.js +14 -0
- package/src/server/server.js +149 -0
- package/src/storage/fileTable.js +22 -0
- package/src/storage/index.js +5 -0
- package/src/storage/storageManager.js +225 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { DatabaseAdapter } from './base.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SQLite adapter backed by better-sqlite3.
|
|
6
|
+
*
|
|
7
|
+
* All operations are synchronous (better-sqlite3's design) which keeps
|
|
8
|
+
* the API simple and avoids callback/promise complexity for a local CLI tool.
|
|
9
|
+
*/
|
|
10
|
+
export class SqliteAdapter extends DatabaseAdapter {
|
|
11
|
+
/** @type {import('better-sqlite3').Database|null} */
|
|
12
|
+
#db = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} filepath Path to the SQLite database file.
|
|
16
|
+
* Defaults to `database.sqlite` in the cwd.
|
|
17
|
+
*/
|
|
18
|
+
constructor(filepath = 'database.sqlite') {
|
|
19
|
+
super();
|
|
20
|
+
this.filepath = filepath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
/* Connection lifecycle */
|
|
25
|
+
/* ------------------------------------------------------------------ */
|
|
26
|
+
|
|
27
|
+
/** @inheritdoc */
|
|
28
|
+
open() {
|
|
29
|
+
if (this.#db) return;
|
|
30
|
+
this.#db = new Database(this.filepath);
|
|
31
|
+
// Enable WAL mode for better concurrent-read performance.
|
|
32
|
+
this.#db.pragma('journal_mode = WAL');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @inheritdoc */
|
|
36
|
+
close() {
|
|
37
|
+
if (!this.#db) return;
|
|
38
|
+
this.#db.close();
|
|
39
|
+
this.#db = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------------------------ */
|
|
43
|
+
/* Query helpers */
|
|
44
|
+
/* ------------------------------------------------------------------ */
|
|
45
|
+
|
|
46
|
+
/** @inheritdoc */
|
|
47
|
+
run(sql, params = []) {
|
|
48
|
+
this.#ensureOpen();
|
|
49
|
+
return this.#db.prepare(sql).run(...params);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @inheritdoc */
|
|
53
|
+
get(sql, params = []) {
|
|
54
|
+
this.#ensureOpen();
|
|
55
|
+
return this.#db.prepare(sql).get(...params);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @inheritdoc */
|
|
59
|
+
all(sql, params = []) {
|
|
60
|
+
this.#ensureOpen();
|
|
61
|
+
return this.#db.prepare(sql).all(...params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @inheritdoc */
|
|
65
|
+
exec(sql) {
|
|
66
|
+
this.#ensureOpen();
|
|
67
|
+
this.#db.exec(sql);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @inheritdoc */
|
|
71
|
+
transaction(fn) {
|
|
72
|
+
this.#ensureOpen();
|
|
73
|
+
const wrapped = this.#db.transaction(() => fn(this));
|
|
74
|
+
return wrapped();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------ */
|
|
78
|
+
/* Internal */
|
|
79
|
+
/* ------------------------------------------------------------------ */
|
|
80
|
+
|
|
81
|
+
#ensureOpen() {
|
|
82
|
+
if (!this.#db) {
|
|
83
|
+
throw new Error('Database is not open. Call adapter.open() first.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SqliteAdapter } from './adapters/sqlite.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Singleton database connection manager.
|
|
5
|
+
*
|
|
6
|
+
* Call `getDatabase()` to obtain the shared adapter instance.
|
|
7
|
+
* The adapter type is selected by `config.adapter` (currently only 'sqlite').
|
|
8
|
+
* Adding PostgreSQL later requires only a new adapter class and a case here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {import('./adapters/base.js').DatabaseAdapter|null} */
|
|
12
|
+
let instance = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return (and lazily create) the singleton database adapter.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ adapter?: 'sqlite', filepath?: string }} [config]
|
|
18
|
+
* @returns {import('./adapters/base.js').DatabaseAdapter}
|
|
19
|
+
*/
|
|
20
|
+
export function getDatabase(config = {}) {
|
|
21
|
+
if (instance) return instance;
|
|
22
|
+
|
|
23
|
+
const { adapter = 'sqlite', filepath } = config;
|
|
24
|
+
|
|
25
|
+
switch (adapter) {
|
|
26
|
+
case 'sqlite':
|
|
27
|
+
instance = new SqliteAdapter(filepath);
|
|
28
|
+
break;
|
|
29
|
+
// Future: case 'postgres': …
|
|
30
|
+
default:
|
|
31
|
+
throw new Error(`Unknown database adapter: "${adapter}"`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
instance.open();
|
|
35
|
+
return instance;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Close the active database connection and discard the singleton.
|
|
40
|
+
*/
|
|
41
|
+
export function closeDatabase() {
|
|
42
|
+
if (!instance) return;
|
|
43
|
+
instance.close();
|
|
44
|
+
instance = null;
|
|
45
|
+
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for the Rotifex database layer.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { getDatabase, closeDatabase, createTable, dropTable, Migrator } from '../db/index.js';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { getDatabase, closeDatabase } from './connection.js';
|
|
9
|
+
export { createTable, dropTable } from './schema.js';
|
|
10
|
+
export { Migrator } from './migrator.js';
|
|
11
|
+
export { DatabaseAdapter } from './adapters/base.js';
|
|
12
|
+
export { SqliteAdapter } from './adapters/sqlite.js';
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File-based migration runner.
|
|
7
|
+
*
|
|
8
|
+
* Migration files live in `migrations/` and are named `NNN_description.js`.
|
|
9
|
+
* Each file must export `up(db)` and `down(db)` functions.
|
|
10
|
+
*
|
|
11
|
+
* Applied migrations are tracked in the `_migrations` table, which records
|
|
12
|
+
* the filename and batch number so rollbacks can undo an entire batch.
|
|
13
|
+
*/
|
|
14
|
+
export class Migrator {
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('./adapters/base.js').DatabaseAdapter} db
|
|
17
|
+
* @param {string} [migrationsDir] Absolute or relative path to the
|
|
18
|
+
* migrations folder (default: `./migrations`).
|
|
19
|
+
*/
|
|
20
|
+
constructor(db, migrationsDir = 'migrations') {
|
|
21
|
+
this.db = db;
|
|
22
|
+
this.migrationsDir = resolve(migrationsDir);
|
|
23
|
+
this.#ensureMigrationsTable();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Public API */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return the list of migration filenames that have not yet been applied.
|
|
32
|
+
* @returns {Promise<string[]>}
|
|
33
|
+
*/
|
|
34
|
+
async pending() {
|
|
35
|
+
const all = await this.#readMigrationFiles();
|
|
36
|
+
const applied = this.#appliedNames();
|
|
37
|
+
return all.filter(f => !applied.has(f));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Apply all pending migrations.
|
|
42
|
+
* @returns {Promise<string[]>} List of filenames that were applied.
|
|
43
|
+
*/
|
|
44
|
+
async up() {
|
|
45
|
+
const pendingFiles = await this.pending();
|
|
46
|
+
if (pendingFiles.length === 0) return [];
|
|
47
|
+
|
|
48
|
+
const batch = this.#nextBatch();
|
|
49
|
+
|
|
50
|
+
for (const file of pendingFiles) {
|
|
51
|
+
const mod = await this.#loadMigration(file);
|
|
52
|
+
this.db.transaction((db) => {
|
|
53
|
+
mod.up(db);
|
|
54
|
+
db.run(
|
|
55
|
+
'INSERT INTO _migrations (name, batch) VALUES (?, ?)',
|
|
56
|
+
[file, batch],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return pendingFiles;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Roll back the latest batch of migrations.
|
|
66
|
+
* @returns {Promise<string[]>} List of filenames that were rolled back.
|
|
67
|
+
*/
|
|
68
|
+
async down() {
|
|
69
|
+
const lastBatch = this.db.get(
|
|
70
|
+
'SELECT MAX(batch) AS batch FROM _migrations',
|
|
71
|
+
);
|
|
72
|
+
if (!lastBatch?.batch) return [];
|
|
73
|
+
|
|
74
|
+
const rows = this.db.all(
|
|
75
|
+
'SELECT name FROM _migrations WHERE batch = ? ORDER BY id DESC',
|
|
76
|
+
[lastBatch.batch],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const mod = await this.#loadMigration(row.name);
|
|
81
|
+
this.db.transaction((db) => {
|
|
82
|
+
mod.down(db);
|
|
83
|
+
db.run('DELETE FROM _migrations WHERE name = ?', [row.name]);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return rows.map(r => r.name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------------------ */
|
|
91
|
+
/* Internal helpers */
|
|
92
|
+
/* ------------------------------------------------------------------ */
|
|
93
|
+
|
|
94
|
+
#ensureMigrationsTable() {
|
|
95
|
+
this.db.exec(`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
97
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
98
|
+
name TEXT NOT NULL UNIQUE,
|
|
99
|
+
batch INTEGER NOT NULL,
|
|
100
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
101
|
+
);
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns {Set<string>} */
|
|
106
|
+
#appliedNames() {
|
|
107
|
+
const rows = this.db.all('SELECT name FROM _migrations');
|
|
108
|
+
return new Set(rows.map(r => r.name));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @returns {number} */
|
|
112
|
+
#nextBatch() {
|
|
113
|
+
const row = this.db.get('SELECT MAX(batch) AS batch FROM _migrations');
|
|
114
|
+
return (row?.batch ?? 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read and sort migration filenames from the migrations directory.
|
|
119
|
+
* @returns {Promise<string[]>}
|
|
120
|
+
*/
|
|
121
|
+
async #readMigrationFiles() {
|
|
122
|
+
try {
|
|
123
|
+
const entries = await readdir(this.migrationsDir);
|
|
124
|
+
return entries
|
|
125
|
+
.filter(f => /^\d+_.+\.js$/.test(f))
|
|
126
|
+
.sort();
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Dynamically import a migration module.
|
|
134
|
+
* @param {string} filename
|
|
135
|
+
* @returns {Promise<{ up: Function, down: Function }>}
|
|
136
|
+
*/
|
|
137
|
+
async #loadMigration(filename) {
|
|
138
|
+
const fullPath = join(this.migrationsDir, filename);
|
|
139
|
+
const fileUrl = pathToFileURL(fullPath).href;
|
|
140
|
+
return import(fileUrl);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/db/schema.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight schema helpers for table creation.
|
|
3
|
+
*
|
|
4
|
+
* Every table automatically receives:
|
|
5
|
+
* - `id` TEXT PRIMARY KEY (UUID v4)
|
|
6
|
+
* - `created_at` TEXT NOT NULL (ISO-8601, defaults to now)
|
|
7
|
+
* - `updated_at` TEXT NOT NULL (ISO-8601, defaults to now)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} ColumnDef
|
|
12
|
+
* @property {string} name Column name.
|
|
13
|
+
* @property {string} type SQL type (TEXT, INTEGER, REAL, BLOB, …).
|
|
14
|
+
* @property {string} [constraints] Extra constraints, e.g. 'NOT NULL UNIQUE'.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a table with automatic `id`, `created_at`, and `updated_at` columns.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('./adapters/base.js').DatabaseAdapter} db
|
|
21
|
+
* @param {string} tableName
|
|
22
|
+
* @param {ColumnDef[]} columns User-defined columns (id & timestamps are added automatically).
|
|
23
|
+
*/
|
|
24
|
+
export function createTable(db, tableName, columns = []) {
|
|
25
|
+
const allColumns = [
|
|
26
|
+
"id TEXT PRIMARY KEY NOT NULL",
|
|
27
|
+
...columns.map(col => {
|
|
28
|
+
const parts = [col.name, col.type];
|
|
29
|
+
if (col.constraints) parts.push(col.constraints);
|
|
30
|
+
return parts.join(' ');
|
|
31
|
+
}),
|
|
32
|
+
"created_at TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
33
|
+
"updated_at TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (\n ${allColumns.join(',\n ')}\n);`;
|
|
37
|
+
db.exec(sql);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Drop a table if it exists.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('./adapters/base.js').DatabaseAdapter} db
|
|
44
|
+
* @param {string} tableName
|
|
45
|
+
*/
|
|
46
|
+
export function dropTable(db, tableName) {
|
|
47
|
+
db.exec(`DROP TABLE IF EXISTS ${tableName};`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadSchema, parseModelDef } from './schemaLoader.js';
|
|
4
|
+
import { syncTables } from './tableSync.js';
|
|
5
|
+
import { registerGenericRoutes } from './routeFactory.js';
|
|
6
|
+
import { initStore, upsertModel } from './schemaStore.js';
|
|
7
|
+
import { logger } from '../lib/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Built-in system models that are always guaranteed to exist.
|
|
11
|
+
* These are written to schema.json on first boot if absent.
|
|
12
|
+
*/
|
|
13
|
+
const SYSTEM_MODELS = {
|
|
14
|
+
User: {
|
|
15
|
+
fields: {
|
|
16
|
+
email: { type: 'string', required: true, unique: true },
|
|
17
|
+
display_name: { type: 'string' },
|
|
18
|
+
role: { type: 'string', default: 'user' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Ensure system models exist in schema.json and the in-memory store.
|
|
25
|
+
* Idempotent — only adds what's missing, never overwrites existing.
|
|
26
|
+
*/
|
|
27
|
+
function ensureSystemModels(schemaPath) {
|
|
28
|
+
const abs = resolve(schemaPath);
|
|
29
|
+
let schema = {};
|
|
30
|
+
if (existsSync(abs)) {
|
|
31
|
+
try { schema = JSON.parse(readFileSync(abs, 'utf-8')); } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let changed = false;
|
|
35
|
+
for (const [name, def] of Object.entries(SYSTEM_MODELS)) {
|
|
36
|
+
if (!schema[name]) {
|
|
37
|
+
schema[name] = def;
|
|
38
|
+
changed = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (changed) writeFileSync(abs, JSON.stringify(schema, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bootstrap the dynamic REST engine.
|
|
47
|
+
*
|
|
48
|
+
* 1. Ensure system models (User) exist in schema.json
|
|
49
|
+
* 2. Load all model definitions into the in-memory store
|
|
50
|
+
* 3. Auto-create DB tables (idempotent)
|
|
51
|
+
* 4. Register five generic /api/:table routes
|
|
52
|
+
*
|
|
53
|
+
* @param {import('fastify').FastifyInstance} app
|
|
54
|
+
* @param {import('../db/adapters/base.js').DatabaseAdapter} db
|
|
55
|
+
* @param {string} [schemaPath]
|
|
56
|
+
*/
|
|
57
|
+
export function bootstrapEngine(app, db, schemaPath = 'schema.json') {
|
|
58
|
+
ensureSystemModels(schemaPath);
|
|
59
|
+
|
|
60
|
+
const models = loadSchema(schemaPath);
|
|
61
|
+
|
|
62
|
+
initStore(models);
|
|
63
|
+
syncTables(db, models);
|
|
64
|
+
registerGenericRoutes(app, db);
|
|
65
|
+
|
|
66
|
+
logger.success(
|
|
67
|
+
`Woke up ${models.size} model(s) and they're ready to party: ${[...models.keys()].join(', ')}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { loadSchema, parseModelDef } from './schemaLoader.js';
|
|
72
|
+
export { syncTables } from './tableSync.js';
|
|
73
|
+
export { registerGenericRoutes } from './routeFactory.js';
|
|
74
|
+
export { buildValidators } from './zodFactory.js';
|
|
75
|
+
export { buildListQuery } from './queryBuilder.js';
|
|
76
|
+
export { initStore, getStore, getModelByTable, upsertModel, removeModel } from './schemaStore.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build parameterised SQL for list queries with filtering, sorting,
|
|
3
|
+
* and pagination.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} tableName
|
|
6
|
+
* @param {object[]} fields Normalised field definitions.
|
|
7
|
+
* @param {object} query Raw query-string object from the request.
|
|
8
|
+
* @returns {{ sql: string, countSql: string, params: any[], page: number, limit: number }}
|
|
9
|
+
*/
|
|
10
|
+
export function buildListQuery(tableName, fields, query = {}) {
|
|
11
|
+
const fieldNames = new Set(fields.map(f => f.name));
|
|
12
|
+
// Also allow filtering on the auto-generated columns
|
|
13
|
+
fieldNames.add('id');
|
|
14
|
+
fieldNames.add('created_at');
|
|
15
|
+
fieldNames.add('updated_at');
|
|
16
|
+
|
|
17
|
+
// ── Filtering ─────────────────────────────────────────────────────
|
|
18
|
+
const whereClauses = [];
|
|
19
|
+
const params = [];
|
|
20
|
+
|
|
21
|
+
for (const [key, value] of Object.entries(query)) {
|
|
22
|
+
if (['sort', 'order', 'page', 'limit'].includes(key)) continue;
|
|
23
|
+
if (!fieldNames.has(key)) continue; // ignore unknown fields
|
|
24
|
+
whereClauses.push(`${key} = ?`);
|
|
25
|
+
params.push(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const whereSQL = whereClauses.length
|
|
29
|
+
? `WHERE ${whereClauses.join(' AND ')}`
|
|
30
|
+
: '';
|
|
31
|
+
|
|
32
|
+
// ── Sorting ───────────────────────────────────────────────────────
|
|
33
|
+
const sortField = fieldNames.has(query.sort) ? query.sort : 'created_at';
|
|
34
|
+
const sortOrder = query.order?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
|
35
|
+
const orderSQL = `ORDER BY ${sortField} ${sortOrder}`;
|
|
36
|
+
|
|
37
|
+
// ── Pagination ────────────────────────────────────────────────────
|
|
38
|
+
const page = Math.max(1, parseInt(query.page, 10) || 1);
|
|
39
|
+
const limit = Math.min(100, Math.max(1, parseInt(query.limit, 10) || 20));
|
|
40
|
+
const offset = (page - 1) * limit;
|
|
41
|
+
|
|
42
|
+
const sql = `SELECT * FROM ${tableName} ${whereSQL} ${orderSQL} LIMIT ? OFFSET ?`;
|
|
43
|
+
const countSql = `SELECT COUNT(*) AS total FROM ${tableName} ${whereSQL}`;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
sql,
|
|
47
|
+
countSql,
|
|
48
|
+
params, // shared between data + count queries
|
|
49
|
+
page,
|
|
50
|
+
limit,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { buildValidators } from './zodFactory.js';
|
|
3
|
+
import { buildListQuery } from './queryBuilder.js';
|
|
4
|
+
import { getModelByTable } from './schemaStore.js';
|
|
5
|
+
|
|
6
|
+
const NOT_FOUND_TABLE = (table) => ({
|
|
7
|
+
error: 'Not Found',
|
|
8
|
+
message: `Unknown resource "${table}"`,
|
|
9
|
+
statusCode: 404,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register five generic CRUD routes that resolve the model from the
|
|
14
|
+
* in-memory schema store at request time.
|
|
15
|
+
*
|
|
16
|
+
* Because the store is updated live (no restart needed), any model added
|
|
17
|
+
* via the admin API is immediately reachable through these routes.
|
|
18
|
+
*
|
|
19
|
+
* GET /:table List (filter + sort + paginate)
|
|
20
|
+
* GET /:table/:id Get by ID
|
|
21
|
+
* POST /:table Create
|
|
22
|
+
* PUT /:table/:id Update (partial)
|
|
23
|
+
* DELETE /:table/:id Delete
|
|
24
|
+
*
|
|
25
|
+
* Static routes registered elsewhere (/health, /files/*, /admin/*)
|
|
26
|
+
* always take priority over these parametric routes in Fastify's router.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('fastify').FastifyInstance} app
|
|
29
|
+
* @param {import('../db/adapters/base.js').DatabaseAdapter} db
|
|
30
|
+
*/
|
|
31
|
+
export function registerGenericRoutes(app, db) {
|
|
32
|
+
|
|
33
|
+
// ── LIST ─────────────────────────────────────────────────────────────
|
|
34
|
+
app.get('/api/:table', (request, reply) => {
|
|
35
|
+
const model = getModelByTable(request.params.table);
|
|
36
|
+
if (!model) return reply.status(404).send(NOT_FOUND_TABLE(request.params.table));
|
|
37
|
+
|
|
38
|
+
const { tableName, fields } = model;
|
|
39
|
+
const { sql, countSql, params, page, limit } = buildListQuery(tableName, fields, request.query);
|
|
40
|
+
|
|
41
|
+
const rows = db.all(sql, [...params, limit, (page - 1) * limit]);
|
|
42
|
+
const { total } = db.get(countSql, params);
|
|
43
|
+
|
|
44
|
+
return { data: rows, meta: { total, page, limit, pages: Math.ceil(total / limit) } };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── GET BY ID ─────────────────────────────────────────────────────────
|
|
48
|
+
app.get('/api/:table/:id', (request, reply) => {
|
|
49
|
+
const model = getModelByTable(request.params.table);
|
|
50
|
+
if (!model) return reply.status(404).send(NOT_FOUND_TABLE(request.params.table));
|
|
51
|
+
|
|
52
|
+
const row = db.get(`SELECT * FROM ${model.tableName} WHERE id = ?`, [request.params.id]);
|
|
53
|
+
if (!row) return reply.status(404).send({ error: 'Not Found', message: `${model.tableName} not found`, statusCode: 404 });
|
|
54
|
+
return { data: row };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── CREATE ────────────────────────────────────────────────────────────
|
|
58
|
+
app.post('/api/:table', (request, reply) => {
|
|
59
|
+
const model = getModelByTable(request.params.table);
|
|
60
|
+
if (!model) return reply.status(404).send(NOT_FOUND_TABLE(request.params.table));
|
|
61
|
+
|
|
62
|
+
const { tableName, fields } = model;
|
|
63
|
+
const { createSchema } = buildValidators(fields);
|
|
64
|
+
const parsed = createSchema.safeParse(request.body);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
return reply.status(400).send({ error: 'Validation Error', message: parsed.error.issues, statusCode: 400 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = parsed.data;
|
|
70
|
+
const id = crypto.randomUUID();
|
|
71
|
+
const now = new Date().toISOString();
|
|
72
|
+
const cols = ['id', ...Object.keys(data), 'created_at', 'updated_at'];
|
|
73
|
+
const sqlParams = [id, ...Object.values(data), now, now]
|
|
74
|
+
.map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v));
|
|
75
|
+
|
|
76
|
+
db.run(`INSERT INTO ${tableName} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, sqlParams);
|
|
77
|
+
return reply.status(201).send({ data: db.get(`SELECT * FROM ${tableName} WHERE id = ?`, [id]) });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── UPDATE ────────────────────────────────────────────────────────────
|
|
81
|
+
app.put('/api/:table/:id', (request, reply) => {
|
|
82
|
+
const model = getModelByTable(request.params.table);
|
|
83
|
+
if (!model) return reply.status(404).send(NOT_FOUND_TABLE(request.params.table));
|
|
84
|
+
|
|
85
|
+
const { tableName, fields } = model;
|
|
86
|
+
const existing = db.get(`SELECT * FROM ${tableName} WHERE id = ?`, [request.params.id]);
|
|
87
|
+
if (!existing) return reply.status(404).send({ error: 'Not Found', message: `${tableName} not found`, statusCode: 404 });
|
|
88
|
+
|
|
89
|
+
const { updateSchema } = buildValidators(fields);
|
|
90
|
+
const parsed = updateSchema.safeParse(request.body);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
return reply.status(400).send({ error: 'Validation Error', message: parsed.error.issues, statusCode: 400 });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = parsed.data;
|
|
96
|
+
if (Object.keys(data).length === 0) {
|
|
97
|
+
return reply.status(400).send({ error: 'Validation Error', message: 'No fields to update', statusCode: 400 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const setClauses = [...Object.keys(data).map(k => `${k} = ?`), 'updated_at = ?'];
|
|
102
|
+
const params = [...Object.values(data).map(v => (typeof v === 'boolean' ? (v ? 1 : 0) : v)), now, request.params.id];
|
|
103
|
+
|
|
104
|
+
db.run(`UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE id = ?`, params);
|
|
105
|
+
return { data: db.get(`SELECT * FROM ${tableName} WHERE id = ?`, [request.params.id]) };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── DELETE ────────────────────────────────────────────────────────────
|
|
109
|
+
app.delete('/api/:table/:id', (request, reply) => {
|
|
110
|
+
const model = getModelByTable(request.params.table);
|
|
111
|
+
if (!model) return reply.status(404).send(NOT_FOUND_TABLE(request.params.table));
|
|
112
|
+
|
|
113
|
+
const existing = db.get(`SELECT * FROM ${model.tableName} WHERE id = ?`, [request.params.id]);
|
|
114
|
+
if (!existing) return reply.status(404).send({ error: 'Not Found', message: `${model.tableName} not found`, statusCode: 404 });
|
|
115
|
+
|
|
116
|
+
db.run(`DELETE FROM ${model.tableName} WHERE id = ?`, [request.params.id]);
|
|
117
|
+
return reply.status(204).send();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Schema-to-SQL type mapping.
|
|
6
|
+
*/
|
|
7
|
+
const SQL_TYPE_MAP = {
|
|
8
|
+
string: 'TEXT',
|
|
9
|
+
number: 'REAL',
|
|
10
|
+
integer: 'INTEGER',
|
|
11
|
+
boolean: 'INTEGER', // SQLite stores booleans as 0/1
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalise a field definition.
|
|
16
|
+
* Supports both shorthand (`"email": "string"`) and full form
|
|
17
|
+
* (`"email": { "type": "string", "required": true }`).
|
|
18
|
+
*/
|
|
19
|
+
function normaliseField(name, raw) {
|
|
20
|
+
if (typeof raw === 'string') {
|
|
21
|
+
return { name, type: raw, sqlType: SQL_TYPE_MAP[raw] || 'TEXT', required: false, unique: false, default: undefined };
|
|
22
|
+
}
|
|
23
|
+
const sqlType = SQL_TYPE_MAP[raw.type] || 'TEXT';
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
type: raw.type,
|
|
27
|
+
sqlType,
|
|
28
|
+
required: raw.required ?? false,
|
|
29
|
+
unique: raw.unique ?? false,
|
|
30
|
+
default: raw.default,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load and parse `schema.json`, returning a Map of model definitions.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} [filepath='schema.json']
|
|
38
|
+
* @returns {Map<string, { tableName: string, fields: object[] }>}
|
|
39
|
+
*/
|
|
40
|
+
export function loadSchema(filepath = 'schema.json') {
|
|
41
|
+
const abs = resolve(filepath);
|
|
42
|
+
const raw = JSON.parse(readFileSync(abs, 'utf-8'));
|
|
43
|
+
const models = new Map();
|
|
44
|
+
|
|
45
|
+
for (const [modelName, modelDef] of Object.entries(raw)) {
|
|
46
|
+
const fieldsRaw = modelDef.fields ?? modelDef; // support both formats
|
|
47
|
+
const fields = Object.entries(fieldsRaw).map(
|
|
48
|
+
([name, def]) => normaliseField(name, def),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Pluralise + lowercase for table / route name
|
|
52
|
+
const tableName = modelName.toLowerCase() + 's';
|
|
53
|
+
|
|
54
|
+
models.set(modelName, { tableName, fields });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return models;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse a single model definition (as stored in schema.json) into a
|
|
62
|
+
* normalised model object that the engine and store understand.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} modelName e.g. "Product"
|
|
65
|
+
* @param {object} modelDef e.g. { fields: { name: { type: 'string', required: true } } }
|
|
66
|
+
* @returns {{ tableName: string, fields: object[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function parseModelDef(modelName, modelDef) {
|
|
69
|
+
const fieldsRaw = modelDef.fields ?? modelDef;
|
|
70
|
+
const fields = Object.entries(fieldsRaw).map(([name, def]) => normaliseField(name, def));
|
|
71
|
+
const tableName = modelName.toLowerCase() + 's';
|
|
72
|
+
return { tableName, fields };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { SQL_TYPE_MAP };
|