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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user