diff --git a/.env.example b/.env.example index 73c9fb4..e8585c1 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ ZEN_SUPPORT_EMAIL=support@exemple.com # DATABASE ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev +ZEN_DB_SSL_DISABLED=false # STORAGE (Cloudflare R2 for now) ZEN_STORAGE_BUCKET=my-bucket-name diff --git a/src/cli/database.js b/src/cli/database.js index 391c356..5c02327 100644 --- a/src/cli/database.js +++ b/src/cli/database.js @@ -16,15 +16,12 @@ dotenv.config({ path: resolve(process.cwd(), '.env.local') }); // The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set process.env.NODE_ENV = process.env.NODE_ENV || 'development'; -import { initDatabase, dropAuthTables, testConnection, closePool } from '../core/database/index.js'; +import { testConnection, closePool } from '../core/database/index.js'; import readline from 'readline'; import { step, done, warn, fail } from '../shared/lib/logger.js'; -async function runCLI() { - const command = process.argv[2]; - - if (!command) { - console.log(` +function printHelp() { + console.log(` Zen Database CLI Usage: @@ -33,22 +30,59 @@ Usage: Commands: init Initialize database (create all required tables) test Test database connection - drop Drop all authentication tables (DANGER!) + drop Drop all tables (DANGER!) help Show this help message Example: npx zen-db init - `); + `); +} + +/** + * Prompt the user for a confirmation answer. + * @param {string} question + * @returns {Promise} The trimmed, lowercased answer + */ +function askConfirmation(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase()); + }); + }); +} + +async function runCLI() { + const command = process.argv[2]; + + if (!command) { + printHelp(); process.exit(0); } try { switch (command) { - case 'init': + case 'init': { step('Initializing database...'); - const result = await initDatabase(); - done(`Created ${result.created.length} tables, skipped ${result.skipped.length} existing tables`); + + const { initFeatures } = await import('../features/init.js'); + const featuresResult = await initFeatures(); + + // Module tables are initialized per-module, if present + let modulesResult = { created: [], skipped: [] }; + try { + const { initModules } = await import('../modules/init.js'); + modulesResult = await initModules(); + } catch { + // Modules may not be present in all project setups — silently skip + } + + const totalCreated = featuresResult.created.length + modulesResult.created.length; + const totalSkipped = featuresResult.skipped.length + modulesResult.skipped.length; + done(`DB ready — ${totalCreated} tables created, ${totalSkipped} skipped`); break; + } case 'test': step('Testing database connection...'); @@ -61,40 +95,22 @@ Example: } break; - case 'drop': - warn('This will delete all authentication tables!'); + case 'drop': { + warn('This will delete all tables!'); process.stdout.write(' Type "yes" to confirm or Ctrl+C to cancel...\n'); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - rl.question('Confirm (yes/no): ', async (answer) => { - if (answer.toLowerCase() === 'yes') { - await dropAuthTables(); - done('Tables dropped successfully'); - } else { - warn('Operation cancelled'); - } - rl.close(); - process.exit(0); - }); - return; // Don't close the process yet + const answer = await askConfirmation('Confirm (yes/no): '); + if (answer === 'yes') { + const { dropFeatures } = await import('../features/init.js'); + await dropFeatures(); + done('Tables dropped successfully'); + } else { + warn('Operation cancelled'); + } + break; + } case 'help': - console.log(` -Zen Database CLI - -Commands: - init Initialize database (create all required tables) - test Test database connection - drop Drop all authentication tables (DANGER!) - help Show this help message - -Usage: - npx zen-db - `); + printHelp(); break; default: @@ -103,12 +119,12 @@ Usage: process.exit(1); } - // Close the database connection pool await closePool(); process.exit(0); } catch (error) { fail(`Error: ${error.message}`); + await closePool(); process.exit(1); } } diff --git a/src/core/database/README.md b/src/core/database/README.md new file mode 100644 index 0000000..ecc5c7b --- /dev/null +++ b/src/core/database/README.md @@ -0,0 +1,292 @@ +# Database + +Ce répertoire est le **module de base de données**. Il fournit une couche d'accès PostgreSQL générique : connexion, requêtes paramétrées, transactions et helpers CRUD. Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins. + +--- + +## Structure + +``` +src/core/database/ +├── index.js Exports publics (query, transaction, create, find, update…) +├── db.js Pool de connexion, DatabaseError, fonctions de requête bas niveau +└── crud.js Helpers CRUD (create, find, update, delete, count, exists…) +``` + +--- + +## Variables d'environnement + +| Variable | Rôle | +|----------|------| +| `ZEN_DATABASE_URL` | URL de connexion PostgreSQL (tous environnements) | +| `ZEN_DATABASE_URL_DEV` | URL de connexion en développement (prioritaire sur `ZEN_DATABASE_URL` si `NODE_ENV=development`) | +| `ZEN_DB_SSL_DISABLED` | Mettre à `true` pour désactiver TLS entièrement (loopback local uniquement) | + +### Politique TLS + +| Environnement | Comportement | +|---------------|-------------| +| `production` | TLS activé, vérification du certificat serveur (`rejectUnauthorized: true`) | +| Autres (`development`, `test`…) | TLS activé, vérification désactivée (accepte les certificats auto-signés) | +| `ZEN_DB_SSL_DISABLED=true` | TLS désactivé (usage local uniquement) | + +--- + +## DatabaseError + +Toutes les opérations base de données lèvent une `DatabaseError` en cas d'échec. Elle expose uniquement un message générique et le code d'erreur PostgreSQL (SQLSTATE), sans jamais retourner de nom de table, de contrainte ou de fragment SQL. + +```js +import { DatabaseError } from '@zen/core/database'; + +try { + await create('users', { email }); +} catch (error) { + if (error instanceof DatabaseError && error.code === '23505') { + // Violation de contrainte unique (ex: email déjà utilisé) + } +} +``` + +Codes SQLSTATE courants : + +| Code | Signification | +|------|--------------| +| `23505` | Violation de contrainte unique (`UNIQUE`) | +| `23503` | Violation de contrainte de clé étrangère (`FOREIGN KEY`) | +| `23502` | Violation de contrainte `NOT NULL` | + +--- + +## Fonctions de requête bas niveau (`db.js`) + +### `query(sql, params?)` + +Exécute une requête SQL paramétrée et retourne l'objet résultat complet `pg`. + +```js +const result = await query('SELECT * FROM users WHERE id = $1', [userId]); +// result.rows, result.rowCount… +``` + +### `queryOne(sql, params?)` + +Retourne la première ligne ou `null`. + +```js +const user = await queryOne('SELECT * FROM users WHERE email = $1', [email]); +``` + +### `queryAll(sql, params?)` + +Retourne toutes les lignes sous forme de tableau. + +```js +const posts = await queryAll('SELECT * FROM posts WHERE published = true'); +``` + +### `transaction(callback)` + +Exécute plusieurs requêtes dans une transaction atomique. Rollback automatique en cas d'erreur. + +```js +const result = await transaction(async (client) => { + const post = await client.query( + 'INSERT INTO posts (title) VALUES ($1) RETURNING *', + ['Mon article'] + ); + await client.query( + 'INSERT INTO post_tags (post_id, tag) VALUES ($1, $2)', + [post.rows[0].id, 'actu'] + ); + return post.rows[0]; +}); +``` + +### `testConnection()` + +Vérifie que la connexion à la base est établie. Retourne `true` ou `false`. + +### `tableExists(tableName)` + +Vérifie si une table existe dans le schéma `public`. Retourne `true` ou `false`. + +```js +if (!(await tableExists('users'))) { + // table absente — migration non appliquée +} +``` + +--- + +## Helpers CRUD (`crud.js`) + +Toutes les fonctions CRUD construisent du SQL **entièrement paramétré**. Les noms de tables et de colonnes passent par `safeIdentifier()` — injection SQL impossible. + +### `create(tableName, data, options?)` + +Insère un enregistrement et retourne la ligne créée (via `RETURNING *`). + +```js +const user = await create('users', { email, name, role: 'user' }); +``` + +Option `allowedColumns` (recommandée) : liste blanche explicite des colonnes autorisées. Toute clé absente de cette liste lève immédiatement une erreur. + +```js +const user = await create('users', body, { + allowedColumns: ['email', 'name'], + // 'role' absent → une tentative de mass-assignment lèvera une erreur +}); +``` + +### `findById(tableName, id, idColumn?)` + +Retourne l'enregistrement correspondant à l'identifiant, ou `null`. + +```js +const post = await findById('posts', 42); +const user = await findById('users', slug, 'slug'); +``` + +### `find(tableName, conditions?, options?)` + +Retourne un tableau d'enregistrements correspondant aux conditions. + +```js +const published = await find('posts', { published: true }, { + orderBy: 'created_at DESC', + limit: 10, + offset: 0, +}); +``` + +Options disponibles : + +| Option | Type | Contrainte | +|--------|------|-----------| +| `orderBy` | `string` | `"colonne"` ou `"colonne ASC\|DESC"` | +| `limit` | `number` | Entier entre 1 et 10 000 | +| `offset` | `number` | Entier ≥ 0 | + +### `findOne(tableName, conditions)` + +Retourne le premier enregistrement correspondant, ou `null`. + +```js +const user = await findOne('users', { email }); +``` + +### `updateById(tableName, id, data, idColumn?, options?)` + +Met à jour un enregistrement par identifiant et retourne la ligne mise à jour, ou `null` si introuvable. + +```js +const updated = await updateById('users', userId, { name: 'Alice' }); + +// Avec whitelist +const updated = await updateById('users', userId, body, 'id', { + allowedColumns: ['name', 'avatar_url'], +}); +``` + +### `update(tableName, conditions, data, options?)` + +Met à jour tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une mise à jour sans condition lève immédiatement une erreur. + +```js +const rows = await update('posts', { author_id: userId }, { published: false }); +``` + +### `deleteById(tableName, id, idColumn?)` + +Supprime un enregistrement par identifiant. Retourne `true` si supprimé, `false` sinon. + +```js +const deleted = await deleteById('posts', postId); +``` + +### `deleteWhere(tableName, conditions)` + +Supprime tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une suppression sans condition lève immédiatement une erreur. Retourne le nombre de lignes supprimées. + +```js +const count = await deleteWhere('sessions', { user_id: userId }); +``` + +### `count(tableName, conditions?)` + +Retourne le nombre d'enregistrements correspondant aux conditions (ou le total si aucune condition). + +```js +const total = await count('users'); +const admins = await count('users', { role: 'admin' }); +``` + +### `exists(tableName, conditions)` + +Retourne `true` si au moins un enregistrement correspond aux conditions. + +```js +const taken = await exists('users', { email }); +``` + +--- + +## Utilitaires de sécurité + +### `safeIdentifier(name)` + +Valide et encadre de guillemets doubles un identifiant SQL (nom de table ou de colonne). Autorise uniquement `[A-Za-z_][A-Za-z0-9_]*`, longueur max 63. Lève une erreur pour tout identifiant invalide. + +```js +safeIdentifier('users') // → '"users"' +safeIdentifier('my-table') // → Error: SQL identifier contains disallowed characters +``` + +### `safeOrderBy(orderBy)` + +Valide une expression `ORDER BY` : `"colonne"` ou `"colonne ASC|DESC"`. Utilise `safeIdentifier` en interne. + +```js +safeOrderBy('created_at DESC') // → '"created_at" DESC' +safeOrderBy('1=1; DROP TABLE') // → Error +``` + +### `filterAllowedColumns(data, allowedColumns?)` + +Filtre un objet de données selon une liste blanche de colonnes. Sans liste blanche, retourne l'objet tel quel. Avec liste blanche, toute clé absente lève immédiatement une erreur. + +```js +filterAllowedColumns({ name: 'Alice', role: 'admin' }, ['name']) +// → Error: Column "role" is not in the permitted columns list +``` + +--- + +## Usage depuis une feature + +```js +// src/features/myfeature/db.js +import { create, find, updateById, deleteById, exists } from '../../core/database/index.js'; + +export async function createItem(data) { + return create('items', data, { allowedColumns: ['title', 'content', 'author_id'] }); +} + +export async function getPublishedItems() { + return find('items', { published: true }, { orderBy: 'created_at DESC', limit: 50 }); +} +``` + +## Usage depuis un module + +```js +// src/modules/mymodule/crud.js +import { create, findById } from '@zen/core/database'; + +export async function createEntry(data) { + return create('mymodule_entries', data, { allowedColumns: ['title', 'slug'] }); +} +``` diff --git a/src/core/database/crud.js b/src/core/database/crud.js index aec3379..9d99adb 100644 --- a/src/core/database/crud.js +++ b/src/core/database/crud.js @@ -5,6 +5,30 @@ import { query, queryOne, queryAll } from './db.js'; +/** + * Filter a data object to only the columns present in allowedColumns. + * If allowedColumns is omitted or empty the original object is returned unchanged + * (backward-compatible default). When a whitelist IS provided, any key not in the + * list causes an immediate throw — mass-assignment of privileged columns (e.g. + * role, email_verified) is therefore impossible when callers supply a whitelist. + * @param {Object} data - Raw data object to filter + * @param {string[]|undefined} allowedColumns - Explicit column whitelist + * @returns {Object} Filtered data object + * @throws {Error} If data contains a column not present in allowedColumns + */ +function filterAllowedColumns(data, allowedColumns) { + if (!allowedColumns || allowedColumns.length === 0) return data; + const allowed = new Set(allowedColumns); + const filtered = {}; + for (const key of Object.keys(data)) { + if (!allowed.has(key)) { + throw new Error(`Column "${key}" is not in the permitted columns list`); + } + filtered[key] = data[key]; + } + return filtered; +} + /** * Validate and safely double-quote a single SQL identifier (table name, column name). * PostgreSQL max identifier length is 63 bytes. Permits only [A-Za-z_][A-Za-z0-9_]*. @@ -49,16 +73,39 @@ function safeOrderBy(orderBy) { return col; } +/** + * Build a parameterized WHERE clause from a conditions object. + * @param {Object} conditions - Column/value pairs to match + * @param {number} startIndex - First $N placeholder index (default: 1) + * @returns {{ clause: string, values: Array }} SQL fragment and bound values + */ +function buildWhere(conditions, startIndex = 1) { + const values = []; + const clauses = Object.keys(conditions).map((key, index) => { + values.push(conditions[key]); + return `${safeIdentifier(key)} = $${startIndex + index}`; + }); + return { clause: clauses.join(' AND '), values }; +} + /** * Insert a new record into a table * @param {string} tableName - Name of the table * @param {Object} data - Object with column names as keys and values to insert + * @param {Object} [options={}] - Options + * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names; + * any key in data not present in this list throws immediately. Omit only when the + * data object is already fully trusted and caller-constructed. * @returns {Promise} Inserted record with all fields */ -async function create(tableName, data) { +async function create(tableName, data, { allowedColumns } = {}) { + if (!data || Object.keys(data).length === 0) { + throw new Error('create() requires at least one data field'); + } + const safeData = filterAllowedColumns(data, allowedColumns); const safeTable = safeIdentifier(tableName); - const columns = Object.keys(data).map(safeIdentifier); - const values = Object.values(data); + const columns = Object.keys(safeData).map(safeIdentifier); + const values = Object.values(safeData); const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); const sql = ` @@ -98,11 +145,9 @@ async function find(tableName, conditions = {}, options = {}) { // Build WHERE clause — column names are validated via safeIdentifier if (Object.keys(conditions).length > 0) { - const whereConditions = Object.keys(conditions).map((key, index) => { - values.push(conditions[key]); - return `${safeIdentifier(key)} = $${index + 1}`; - }); - sql += ` WHERE ${whereConditions.join(' AND ')}`; + const { clause, values: whereValues } = buildWhere(conditions); + values.push(...whereValues); + sql += ` WHERE ${clause}`; } // Add ORDER BY — validated and quoted via safeOrderBy @@ -149,14 +194,21 @@ async function findOne(tableName, conditions) { * @param {string} tableName - Name of the table * @param {number|string} id - ID of the record * @param {Object} data - Object with column names as keys and new values - * @param {string} idColumn - Name of the ID column (default: 'id') + * @param {string} [idColumn='id'] - Name of the ID column + * @param {Object} [options={}] - Options + * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names; + * any key in data not in this list throws immediately. * @returns {Promise} Updated record or null if not found */ -async function updateById(tableName, id, data, idColumn = 'id') { +async function updateById(tableName, id, data, idColumn = 'id', { allowedColumns } = {}) { + if (!data || Object.keys(data).length === 0) { + throw new Error('updateById() requires at least one data field'); + } + const safeData = filterAllowedColumns(data, allowedColumns); const safeTable = safeIdentifier(tableName); const safeIdCol = safeIdentifier(idColumn); - const columns = Object.keys(data).map(safeIdentifier); - const values = Object.values(data); + const columns = Object.keys(safeData).map(safeIdentifier); + const values = Object.values(safeData); const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', '); @@ -176,34 +228,37 @@ async function updateById(tableName, id, data, idColumn = 'id') { * @param {string} tableName - Name of the table * @param {Object} conditions - Object with column names as keys and values to match * @param {Object} data - Object with column names as keys and new values + * @param {Object} [options={}] - Options + * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names; + * any key in data not in this list throws immediately. * @returns {Promise} Array of updated records */ -async function update(tableName, conditions, data) { +async function update(tableName, conditions, data, { allowedColumns } = {}) { // Reject unconditional updates — a missing WHERE clause would silently mutate // every row in the table. Callers must always supply at least one condition. if (!conditions || Object.keys(conditions).length === 0) { throw new Error('update() requires at least one condition to prevent full-table mutation'); } + if (!data || Object.keys(data).length === 0) { + throw new Error('update() requires at least one data field'); + } + const safeData = filterAllowedColumns(data, allowedColumns); const safeTable = safeIdentifier(tableName); - const dataColumns = Object.keys(data).map(safeIdentifier); - const dataValues = Object.values(data); + const dataColumns = Object.keys(safeData).map(safeIdentifier); + const dataValues = Object.values(safeData); const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', '); - let paramIndex = dataValues.length + 1; - const whereConditions = Object.keys(conditions).map((key) => { - dataValues.push(conditions[key]); - return `${safeIdentifier(key)} = $${paramIndex++}`; - }); + const { clause: whereClause, values: whereValues } = buildWhere(conditions, dataValues.length + 1); const sql = ` UPDATE ${safeTable} SET ${setClause} - WHERE ${whereConditions.join(' AND ')} + WHERE ${whereClause} RETURNING * `; - const result = await query(sql, dataValues); + const result = await query(sql, [...dataValues, ...whereValues]); return result.rows; } @@ -232,13 +287,8 @@ async function deleteWhere(tableName, conditions) { if (!conditions || Object.keys(conditions).length === 0) { throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion'); } - const values = []; - const whereConditions = Object.keys(conditions).map((key, index) => { - values.push(conditions[key]); - return `${safeIdentifier(key)} = $${index + 1}`; - }); - - const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${whereConditions.join(' AND ')} RETURNING *`; + const { clause, values } = buildWhere(conditions); + const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${clause} RETURNING *`; const result = await query(sql, values); return result.rowCount; } @@ -251,14 +301,12 @@ async function deleteWhere(tableName, conditions) { */ async function count(tableName, conditions = {}) { let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`; - const values = []; + let values = []; if (Object.keys(conditions).length > 0) { - const whereConditions = Object.keys(conditions).map((key, index) => { - values.push(conditions[key]); - return `${safeIdentifier(key)} = $${index + 1}`; - }); - sql += ` WHERE ${whereConditions.join(' AND ')}`; + const { clause, values: whereValues } = buildWhere(conditions); + values = whereValues; + sql += ` WHERE ${clause}`; } const result = await queryOne(sql, values); @@ -277,6 +325,9 @@ async function exists(tableName, conditions) { } export { + filterAllowedColumns, + safeIdentifier, + safeOrderBy, create, findById, find, diff --git a/src/core/database/db.js b/src/core/database/db.js index 1ccc80d..45618b3 100644 --- a/src/core/database/db.js +++ b/src/core/database/db.js @@ -7,6 +7,22 @@ import pkg from 'pg'; const { Pool } = pkg; import { fail } from '../../shared/lib/logger.js'; +/** + * Opaque error type thrown by all database operations. + * Exposes only a generic message and the pg error code (e.g. '23505') so + * callers can branch on well-known codes without receiving internal details + * such as table names, constraint names, or query fragments. + */ +export class DatabaseError extends Error { + /** @param {string} message - Safe, generic message */ + /** @param {string|undefined} code - PostgreSQL error code (SQLSTATE), if any */ + constructor(message, code) { + super(message); + this.name = 'DatabaseError'; + if (code !== undefined) this.code = code; + } +} + let pool = null; function resolveDatabaseUrl() { @@ -35,9 +51,16 @@ function getPool() { pool = new Pool({ connectionString: databaseUrl, - // rejectUnauthorized MUST remain true in production to validate the server's - // TLS certificate chain and prevent man-in-the-middle attacks. - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false, + // TLS policy: + // production → full TLS with server certificate verification + // all other environments → TLS encryption on, certificate verification off + // (prevents eavesdropping; allows self-signed certs in dev/ci) + // ZEN_DB_SSL_DISABLED=true → opt-out of TLS entirely (local loopback only) + ssl: process.env.NODE_ENV === 'production' + ? { rejectUnauthorized: true } + : (process.env.ZEN_DB_SSL_DISABLED === 'true' + ? false + : { rejectUnauthorized: false }), max: 20, // Maximum number of clients in the pool idleTimeoutMillis: 30000, // Close idle clients after 30 seconds connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established @@ -59,14 +82,14 @@ function getPool() { * @returns {Promise} Query result */ async function query(sql, params = []) { - const client = getPool(); - + const dbPool = getPool(); // renamed — avoids shadowing module-level `pool` + try { - const result = await client.query(sql, params); + const result = await dbPool.query(sql, params); return result; } catch (error) { fail(`DB query error: ${error.message}`); - throw error; + throw new DatabaseError('A database error occurred', error.code); } } @@ -108,7 +131,7 @@ async function transaction(callback) { } catch (error) { await client.query('ROLLBACK'); fail(`DB transaction error: ${error.message}`); - throw error; + throw new DatabaseError('A database transaction error occurred', error.code); } finally { client.release(); } @@ -139,13 +162,32 @@ async function testConnection() { } } +/** + * Check if a table exists in the database + * @param {string} tableName - Name of the table to check + * @returns {Promise} True if table exists, false otherwise + */ +async function tableExists(tableName) { + const result = await query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + )`, + [tableName] + ); + return result.rows[0].exists; +} + export { + DatabaseError, query, queryOne, queryAll, transaction, getPool, closePool, - testConnection + testConnection, + tableExists }; diff --git a/src/core/database/index.js b/src/core/database/index.js index 73b7cd0..7a0e0fe 100644 --- a/src/core/database/index.js +++ b/src/core/database/index.js @@ -5,17 +5,22 @@ // Core database functions export { + DatabaseError, query, queryOne, queryAll, transaction, getPool, closePool, - testConnection + testConnection, + tableExists } from './db.js'; // CRUD helper functions export { + filterAllowedColumns, + safeIdentifier, + safeOrderBy, create, findById, find, @@ -27,12 +32,3 @@ export { count, exists } from './crud.js'; - -// Database initialization -export { - initDatabase, - createAuthTables, - tableExists, - dropAuthTables -} from './init.js'; - diff --git a/src/core/database/init.js b/src/core/database/init.js deleted file mode 100644 index 348afd0..0000000 --- a/src/core/database/init.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Database Initialization - * Creates required tables if they don't exist - */ - -import { query } from './db.js'; -import { step, done, warn, fail, info } from '../../shared/lib/logger.js'; - -/** - * Check if a table exists in the database - * @param {string} tableName - Name of the table to check - * @returns {Promise} True if table exists, false otherwise - */ -async function tableExists(tableName) { - const result = await query( - `SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = $1 - )`, - [tableName] - ); - return result.rows[0].exists; -} - -/** - * Create authentication tables - * @returns {Promise} - */ -async function createAuthTables() { - const tables = [ - { - name: 'zen_auth_users', - sql: ` - CREATE TABLE zen_auth_users ( - id text NOT NULL PRIMARY KEY, - name text NOT NULL, - email text NOT NULL UNIQUE, - email_verified boolean NOT NULL DEFAULT false, - image text, - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - role text DEFAULT 'user' CHECK (role IN ('admin', 'user')) - ) - ` - }, - { - name: 'zen_auth_sessions', - sql: ` - CREATE TABLE zen_auth_sessions ( - id text NOT NULL PRIMARY KEY, - expires_at timestamptz NOT NULL, - token text NOT NULL UNIQUE, - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamptz NOT NULL, - ip_address text, - user_agent text, - user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE - ) - ` - }, - { - name: 'zen_auth_accounts', - sql: ` - CREATE TABLE zen_auth_accounts ( - id text NOT NULL PRIMARY KEY, - account_id text NOT NULL, - provider_id text NOT NULL, - user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE, - access_token text, - refresh_token text, - id_token text, - access_token_expires_at timestamptz, - refresh_token_expires_at timestamptz, - scope text, - password text, - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamptz NOT NULL - ) - ` - }, - { - name: 'zen_auth_verifications', - sql: ` - CREATE TABLE zen_auth_verifications ( - id text NOT NULL PRIMARY KEY, - identifier text NOT NULL, - value text NOT NULL, - token text NOT NULL, - expires_at timestamptz NOT NULL, - created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL - ) - ` - } - ]; - - const created = []; - const skipped = []; - - for (const table of tables) { - const exists = await tableExists(table.name); - - if (!exists) { - await query(table.sql); - created.push(table.name); - done(`Created table: ${table.name}`); - } else { - skipped.push(table.name); - info(`Table already exists: ${table.name}`); - } - } - - return { - created, - skipped, - success: true - }; -} - -/** - * Initialize the database with all required tables - * @returns {Promise} Result object with created and skipped tables - */ -async function initDatabase() { - step('Initializing Zen database...'); - - try { - const authResult = await createAuthTables(); - - // Initialize modules - let modulesResult = { created: [], skipped: [] }; - try { - const { initModules } = await import('../../modules/init.js'); - modulesResult = await initModules(); - } catch (error) { - // Modules might not be available or enabled - info('No modules to initialize or modules not available'); - } - - done(`DB ready — auth: ${authResult.created.length} created, modules: ${modulesResult.created.length} created, ${authResult.skipped.length + modulesResult.skipped.length} skipped`); - - return { - created: [...authResult.created, ...modulesResult.created], - skipped: [...authResult.skipped, ...modulesResult.skipped], - success: true - }; - } catch (error) { - fail(`DB initialization failed: ${error.message}`); - throw error; - } -} - -/** - * Drop all Zen authentication tables (use with caution!) - * @returns {Promise} - */ -async function dropAuthTables() { - const tables = [ - 'zen_auth_verifications', - 'zen_auth_accounts', - 'zen_auth_sessions', - 'zen_auth_users' - ]; - - warn('Dropping all Zen authentication tables...'); - - for (const tableName of tables) { - const exists = await tableExists(tableName); - if (exists) { - await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`); - done(`Dropped table: ${tableName}`); - } - } - - done('All authentication tables dropped'); -} - -export { - initDatabase, - createAuthTables, - tableExists, - dropAuthTables -}; - diff --git a/src/features/auth/db.js b/src/features/auth/db.js new file mode 100644 index 0000000..6a59126 --- /dev/null +++ b/src/features/auth/db.js @@ -0,0 +1,117 @@ +/** + * Auth Feature - Database + * Creates and drops zen_auth_* tables. + */ + +import { query, tableExists } from '@zen/core/database'; +import { done, warn } from '../../shared/lib/logger.js'; + +const AUTH_TABLES = [ + { + name: 'zen_auth_users', + sql: ` + CREATE TABLE zen_auth_users ( + id text NOT NULL PRIMARY KEY, + name text NOT NULL, + email text NOT NULL UNIQUE, + email_verified boolean NOT NULL DEFAULT false, + image text, + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + role text DEFAULT 'user' CHECK (role IN ('admin', 'user')) + ) + ` + }, + { + name: 'zen_auth_sessions', + sql: ` + CREATE TABLE zen_auth_sessions ( + id text NOT NULL PRIMARY KEY, + expires_at timestamptz NOT NULL, + token text NOT NULL UNIQUE, + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamptz NOT NULL, + ip_address text, + user_agent text, + user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE + ) + ` + }, + { + name: 'zen_auth_accounts', + sql: ` + CREATE TABLE zen_auth_accounts ( + id text NOT NULL PRIMARY KEY, + account_id text NOT NULL, + provider_id text NOT NULL, + user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE, + access_token text, + refresh_token text, + id_token text, + access_token_expires_at timestamptz, + refresh_token_expires_at timestamptz, + scope text, + password text, + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamptz NOT NULL + ) + ` + }, + { + name: 'zen_auth_verifications', + sql: ` + CREATE TABLE zen_auth_verifications ( + id text NOT NULL PRIMARY KEY, + identifier text NOT NULL, + value text NOT NULL, + token text NOT NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + ` + } +]; + +/** + * Create all authentication tables. + * @returns {Promise<{ created: string[], skipped: string[] }>} + */ +export async function createTables() { + const created = []; + const skipped = []; + + for (const table of AUTH_TABLES) { + const exists = await tableExists(table.name); + + if (!exists) { + await query(table.sql); + created.push(table.name); + done(`Created table: ${table.name}`); + } else { + skipped.push(table.name); + } + } + + return { created, skipped }; +} + +/** + * Drop all authentication tables in reverse dependency order. + * @returns {Promise} + */ +export async function dropTables() { + const dropOrder = [...AUTH_TABLES].reverse().map(t => t.name); + + warn('Dropping all Zen authentication tables...'); + + for (const tableName of dropOrder) { + const exists = await tableExists(tableName); + if (exists) { + await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`); + done(`Dropped table: ${tableName}`); + } + } + + done('All authentication tables dropped'); +} diff --git a/src/features/features.registry.js b/src/features/features.registry.js new file mode 100644 index 0000000..4678f43 --- /dev/null +++ b/src/features/features.registry.js @@ -0,0 +1,13 @@ +/** + * Core Features Registry + * + * Lists all built-in features that are always initialized when running `zen-db init`. + * Unlike optional modules (src/modules), core features are not gated by env vars — + * they are required for the application to function. + * + * Each name must correspond to a directory under src/features/ that exposes a db.js + * with createTables() and optionally dropTables(). + */ +export const CORE_FEATURES = [ + 'auth', +]; diff --git a/src/features/init.js b/src/features/init.js new file mode 100644 index 0000000..41c2791 --- /dev/null +++ b/src/features/init.js @@ -0,0 +1,65 @@ +/** + * Core Feature Database Initialization (CLI) + * + * Initializes and drops DB tables for each core feature. + * Features are discovered from CORE_FEATURES — no manual wiring needed + * when adding a new feature. + */ + +import { CORE_FEATURES } from './features.registry.js'; +import { done, fail, info, step } from '../shared/lib/logger.js'; + +/** + * Initialize all core feature databases. + * @returns {Promise<{ created: string[], skipped: string[] }>} + */ +export async function initFeatures() { + const created = []; + const skipped = []; + + step('Initializing feature databases...'); + + for (const featureName of CORE_FEATURES) { + try { + step(`Initializing ${featureName}...`); + const db = await import(`./${featureName}/db.js`); + + if (typeof db.createTables === 'function') { + const result = await db.createTables(); + + if (result?.created) created.push(...result.created); + if (result?.skipped) skipped.push(...result.skipped); + + done(`${featureName} initialized`); + } else { + info(`${featureName} has no createTables function`); + } + } catch (error) { + fail(`${featureName}: ${error.message}`); + throw error; + } + } + + return { created, skipped }; +} + +/** + * Drop all core feature databases in reverse order. + * @returns {Promise} + */ +export async function dropFeatures() { + for (const featureName of [...CORE_FEATURES].reverse()) { + try { + const db = await import(`./${featureName}/db.js`); + + if (typeof db.dropTables === 'function') { + await db.dropTables(); + } else { + info(`${featureName} has no dropTables function`); + } + } catch (error) { + fail(`${featureName}: ${error.message}`); + throw error; + } + } +} diff --git a/src/modules/posts/db.js b/src/modules/posts/db.js index 1e470d4..1b8f01d 100644 --- a/src/modules/posts/db.js +++ b/src/modules/posts/db.js @@ -3,22 +3,10 @@ * Creates zen_posts and zen_posts_category tables. */ -import { query } from '@zen/core/database'; +import { query, tableExists } from '@zen/core/database'; import { getPostsConfig } from './config.js'; import { done, info, step } from '../../shared/lib/logger.js'; -async function tableExists(tableName) { - const result = await query( - `SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = $1 - )`, - [tableName] - ); - return result.rows[0].exists; -} - async function createPostsCategoryTable() { const tableName = 'zen_posts_category'; const exists = await tableExists(tableName);