feat(database): refactor CLI, add column whitelist, and SSL config

- Add `ZEN_DB_SSL_DISABLED` env variable to allow disabling SSL for database connections
- Refactor database CLI to split init logic into `initFeatures` and `initModules` for modular table initialization, with graceful fallback when modules are absent
- Extract `printHelp` and `askConfirmation` helpers for cleaner CLI structure
- Ensure `closePool` is called on both success and error paths in CLI
- Add `filterAllowedColumns` utility in `crud.js` to enforce column whitelists, preventing mass-assignment of privileged fields (e.g. `role`, `email_verified`)
- Update drop command description from "auth tables" to "all tables"
This commit is contained in:
2026-04-13 16:35:23 -04:00
parent 6521179e10
commit a3921a0b98
11 changed files with 691 additions and 295 deletions
+1
View File
@@ -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
+59 -43
View File
@@ -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<string>} 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 <command>
`);
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);
}
}
+292
View File
@@ -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'] });
}
```
+86 -35
View File
@@ -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<Object>} 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<Object|null>} 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>} 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,
+50 -8
View File
@@ -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<Object>} 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<boolean>} 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
};
+6 -10
View File
@@ -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';
-185
View File
@@ -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<boolean>} 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<void>}
*/
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<Object>} 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<void>}
*/
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
};
+117
View File
@@ -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<void>}
*/
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');
}
+13
View File
@@ -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',
];
+65
View File
@@ -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<void>}
*/
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;
}
}
}
+1 -13
View File
@@ -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);