StrictDB Documentation
Complete API reference for strictdb and strictdb-mcp. One unified API for MongoDB, PostgreSQL, MySQL, MSSQL, SQLite, and Elasticsearch.
Installation
Install the core package and the peer dependency for your database:
# Core package
npm install strictdb
# Add your database driver (pick one or more)
npm install mongodb # MongoDB
npm install pg # PostgreSQL
npm install mysql2 # MySQL
npm install mssql # MSSQL
npm install better-sqlite3 # SQLite
npm install @elastic/elasticsearch # Elasticsearch
Quick Start
StrictDB auto-detects your backend from the connection URI. No configuration files, no schema definitions required.
import { StrictDB } from 'strictdb';
// Auto-detects backend from URI
const db = await StrictDB.create({
uri: 'postgresql://user:pass@localhost:5432/mydb'
});
// Read
const user = await db.queryOne('users', { email: 'alice@example.com' });
// Write — returns a receipt, never void
const receipt = await db.insertOne('users', {
email: 'bob@example.com',
name: 'Bob',
role: 'user'
});
console.log(receipt.insertedCount); // 1
// Update with MongoDB-style operators
await db.updateOne('users',
{ email: 'bob@example.com' },
{ $set: { role: 'admin' }, $inc: { loginCount: 1 } }
);
// Same code works on Mongo, Postgres, MySQL, MSSQL, SQLite, Elasticsearch
await db.close();
URI Auto-Detection
| URI Prefix | Backend | Driver |
|---|---|---|
mongodb:// / mongodb+srv:// | mongo | mongodb |
postgresql:// / postgres:// | sql | pg |
mysql:// | sql | mysql2 |
mssql:// | sql | mssql |
sqlite: / file: | sql | better-sqlite3 |
http:// / https:// | elastic | @elastic/elasticsearch |
Configuration
const db = await StrictDB.create({
uri: 'postgresql://localhost:5432/mydb', // required
pool: 'standard', // 'high' | 'standard' | 'low'
dbName: 'mydb', // override database name
label: 'primary', // logging label
schema: false, // enable Zod validation on writes
sanitize: true, // input sanitization (default: true)
guardrails: { // or just true/false
limitRequired: true, // block unbounded queries (default: true)
emptyFilter: true, // block empty-filter deletes/updates (default: true)
nullComparison: true, // block = NULL in SQL mode (default: true)
},
logging: true, // true | false | 'verbose'
slowQueryMs: 1000, // slow query threshold in ms
timestamps: true, // auto createdAt/updatedAt injection
sanitizeRules: [], // custom SanitizeRule[] array
reconnect: { // or just true/false
enabled: true,
maxAttempts: 10,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2
},
elastic: { // Elasticsearch-specific options
apiKey: '...',
caFingerprint: '...',
sniffOnStart: false
}
});
Read Operations
queryOne<T>(collection, filter, options?)
Find a single document matching the filter. Returns null if not found.
const user = await db.queryOne<User>('users', { email: 'alice@example.com' });
// → User | null
// With projection — only return specific fields
const user = await db.queryOne('users',
{ email: 'alice@example.com' },
{ projection: { name: 1, email: 1 } }
);
queryMany<T>(collection, filter, options?)
Find multiple documents. Limit is required — guardrails block unbounded queries.
const admins = await db.queryMany<User>('users', {
role: 'admin',
status: { $in: ['active', 'pending'] },
age: { $gte: 18 }
}, {
sort: { createdAt: -1 },
limit: 50,
skip: 0
});
// → User[]
queryWithLookup<T>(collection, options)
Join across collections. Uses $lookup on MongoDB, two-query approach on SQL/ES.
const order = await db.queryWithLookup<OrderWithUser>('orders', {
match: { orderId: 'ORD-001' },
lookup: {
from: 'users', // related collection
localField: 'userId', // field in orders
foreignField: '_id', // field in users
as: 'user', // nest result here
type: 'left' // 'left' | 'inner'
},
sort: { createdAt: -1 },
limit: 1
});
// → { orderId: 'ORD-001', userId: '...', user: { name: 'Alice', ... } }
$lookup aggregation. SQL backends execute two queries (main + related) and nest the results. Elasticsearch uses the same two-query approach.
count<T>(collection, filter?)
const total = await db.count('users', { role: 'admin' });
// → number
QueryOptions
| Option | Type | Description |
|---|---|---|
sort | { field: 1 | -1 } | Sort direction. 1 = ascending, -1 = descending |
limit | number | Max documents to return. Required for queryMany |
skip | number | Number of documents to skip (pagination) |
projection | { field: 0 | 1 } | Include (1) or exclude (0) fields from results |
Write Operations
All write operations return an OperationReceipt — never void.
insertOne<T>(collection, doc)
const receipt = await db.insertOne('users', {
email: 'alice@example.com',
name: 'Alice',
role: 'user',
age: 28
});
// receipt.insertedCount === 1
insertMany<T>(collection, docs)
const receipt = await db.insertMany('users', [
{ email: 'alice@example.com', name: 'Alice', role: 'user' },
{ email: 'bob@example.com', name: 'Bob', role: 'admin' },
]);
// receipt.insertedCount === 2
updateOne<T>(collection, filter, update, upsert?)
Updates a single document. Enforces single-row semantics on all SQL dialects.
await db.updateOne('users',
{ email: 'alice@example.com' },
{ $set: { role: 'admin' }, $inc: { loginCount: 1 } }
);
// Upsert — insert if not found
await db.updateOne('users',
{ email: 'new@example.com' },
{ $set: { name: 'New User', role: 'user' } },
true // upsert
);
updateMany<T>(collection, filter, update, options?)
Update all matching documents. Empty filter is blocked by guardrails.
// Update all inactive users
await db.updateMany('users',
{ status: 'inactive', lastLogin: { $lt: cutoffDate } },
{ $set: { archived: true } }
);
// To update ALL documents, you must confirm:
await db.updateMany('users',
{ _id: { $exists: true } },
{ $set: { migrated: true } },
{ confirm: 'UPDATE_ALL' }
);
deleteOne<T>(collection, filter)
await db.deleteOne('users', { email: 'old@example.com' });
deleteMany<T>(collection, filter, options?)
// Delete matching documents
await db.deleteMany('logs', { createdAt: { $lt: thirtyDaysAgo } });
// Delete ALL requires explicit confirmation
await db.deleteMany('temp_data',
{ _id: { $exists: true } },
{ confirm: 'DELETE_ALL' }
);
SQL Mode — Overview & API
Execute SQL on any backend — including MongoDB. On MongoDB, StrictDB parses the SQL, generates aggregate pipelines, and executes them natively. On SQL databases, it routes the query to the native driver. On Elasticsearch, SELECT queries translate to Query DSL.
// db.sql() works on ALL backends
const results = await db.sql(`
SELECT u.name, COUNT(o.id) AS orders
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.name
HAVING COUNT(o.id) > 5
ORDER BY orders DESC
LIMIT 10
`);
// On MongoDB: translates to $lookup, $unwind, $match, $group, $sort, $limit
// On PostgreSQL/MySQL/MSSQL/SQLite: executes SQL natively
Signature
db.sql(sql: string, options?: SqlOptions): Promise<SqlMode2Result | OperationReceipt>
Options
| Option | Type | Default | Description |
|---|---|---|---|
params | unknown[] | — | Parameterized query values (? or $1 placeholders) |
dialect | string | 'mysql' | SQL dialect: 'mysql', 'postgresql', 'mariadb', 'sqlite', 'bigquery' |
explain | boolean | false | Return execution plan alongside results |
raw | boolean | false | SQL backends only — bypass pipeline translation, pass SQL directly to driver |
Return Types
SELECT queries return SqlMode2Result:
{ data: Record<string, unknown>[], plan?: ExplainPlan }
Write queries (INSERT, UPDATE, DELETE) return OperationReceipt:
{ operation, success, matchedCount, modifiedCount, insertedCount, deletedCount, duration, backend }
Supported Features
SQL Mode supports a comprehensive set of SQL features across all backends. On SQL databases, queries execute natively. On MongoDB, every feature is translated to aggregation pipeline stages.
| Feature | MongoDB | PostgreSQL | MySQL | MSSQL | SQLite | ES |
|---|---|---|---|---|---|---|
| SELECT + WHERE | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| INSERT / UPDATE / DELETE | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| JOINs (5 types) | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Subqueries | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| CTEs (WITH) | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Window Functions | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Aggregation (GROUP BY) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| UNION / UNION ALL | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Parameterized Queries | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Transactions | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| 30+ SQL Functions | ✓ | ✓ | ✓ | ✓ | ✓ | — |
JOINs
All five standard JOIN types are supported. On MongoDB, JOINs translate to $lookup and $unwind pipeline stages.
| JOIN Type | MongoDB Translation |
|---|---|
| INNER JOIN | $lookup + $unwind (preserveNullAndEmptyArrays: false) |
| LEFT JOIN | $lookup + $unwind (preserveNullAndEmptyArrays: true) |
| RIGHT JOIN | Swaps main collection, then LEFT JOIN |
| FULL OUTER JOIN | Two parallel pipelines merged with $unionWith |
| CROSS JOIN | $lookup without conditions + $unwind |
// INNER JOIN
const results = await db.sql(`
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
LIMIT 20
`);
// FULL OUTER JOIN — runs two pipelines in parallel
const all = await db.sql(`
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
LIMIT 50
`);
// Multi-condition JOIN
const matched = await db.sql(`
SELECT a.*, b.score
FROM students a
JOIN grades b ON a.id = b.student_id AND a.semester = b.semester
LIMIT 100
`);
Subqueries
Subqueries are resolved as dependencies before the main query executes. On MongoDB, results are injected into the pipeline via $match with $in, $nin, or $expr.
// WHERE IN (subquery)
const active = await db.sql(`
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100)
LIMIT 50
`);
// WHERE NOT IN
const inactive = await db.sql(`
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM logins WHERE last_seen > '2025-01-01')
LIMIT 50
`);
// WHERE EXISTS
const withOrders = await db.sql(`
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
LIMIT 50
`);
// Scalar subquery
const above = await db.sql(`
SELECT * FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees)
LIMIT 50
`);
CTEs (Common Table Expressions)
WITH clauses are resolved as dependencies before the main query. Multiple CTEs can reference each other. WITH RECURSIVE translates to $graphLookup on MongoDB.
// Basic CTE
const result = await db.sql(`
WITH active_users AS (
SELECT * FROM users WHERE status = 'active'
)
SELECT name, email FROM active_users
ORDER BY name
LIMIT 100
`);
// Multiple CTEs with dependencies
const report = await db.sql(`
WITH
dept_totals AS (
SELECT department, SUM(salary) as total
FROM employees
GROUP BY department
),
avg_total AS (
SELECT AVG(total) as avg_dept FROM dept_totals
)
SELECT department, total FROM dept_totals
WHERE total > (SELECT avg_dept FROM avg_total)
LIMIT 50
`);
// Recursive CTE (translates to $graphLookup on MongoDB)
const tree = await db.sql(`
WITH RECURSIVE org AS (
SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id
FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT * FROM org LIMIT 500
`);
Window Functions
Window functions translate to $setWindowFields on MongoDB. Supports PARTITION BY and ORDER BY within the window specification.
| Function | Description |
|---|---|
ROW_NUMBER() | Sequential row number within partition |
RANK() | Rank with gaps for ties |
DENSE_RANK() | Rank without gaps |
LAG(field, offset) | Access previous row's value |
LEAD(field, offset) | Access next row's value |
// RANK with PARTITION BY
const ranked = await db.sql(`
SELECT name, department, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees
LIMIT 100
`);
// LAG — compare to previous row
const deltas = await db.sql(`
SELECT date, revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY date) AS daily_change
FROM daily_stats
LIMIT 90
`);
Aggregation & GROUP BY
Full aggregation support with GROUP BY, HAVING, and aggregate functions. On MongoDB, these translate to $group and $match stages.
| Function | MongoDB Equivalent |
|---|---|
COUNT(*), COUNT(field) | $sum: 1 / $sum: $cond |
COUNT(DISTINCT field) | $addToSet + $size |
SUM(field) | $sum |
AVG(field) | $avg |
MIN(field), MAX(field) | $min, $max |
GROUP_CONCAT(field) | $push + $reduce |
ARRAY_AGG(field) | $push |
STDDEV(field) | $stdDevPop |
// GROUP BY with HAVING
const stats = await db.sql(`
SELECT department, COUNT(*) as headcount, AVG(salary) as avg_salary
FROM employees
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY avg_salary DESC
LIMIT 20
`);
// UNION ALL
const combined = await db.sql(`
SELECT name, 'employee' as type FROM employees
UNION ALL
SELECT name, 'contractor' as type FROM contractors
LIMIT 100
`);
SQL Functions
30+ SQL functions are supported and automatically translated to MongoDB expressions. On SQL backends, functions execute natively.
String Functions
| Function | Example |
|---|---|
UPPER(), LOWER() | SELECT UPPER(name) FROM users |
TRIM(), LTRIM(), RTRIM() | SELECT TRIM(email) FROM users |
CONCAT(), CONCAT_WS() | SELECT CONCAT(first, ' ', last) FROM users |
LENGTH(), CHAR_LENGTH() | SELECT LENGTH(name) FROM users |
SUBSTRING(), LEFT(), RIGHT() | SELECT SUBSTRING(name, 1, 3) FROM users |
REPLACE(), REVERSE() | SELECT REPLACE(email, '@old', '@new') FROM users |
POSITION(), LOCATE(), INSTR() | SELECT POSITION('@' IN email) FROM users |
Numeric Functions
| Function | Example |
|---|---|
ROUND(), ABS() | SELECT ROUND(price, 2) FROM products |
CEIL(), FLOOR(), TRUNC() | SELECT CEIL(rating) FROM reviews |
POWER(), SQRT(), EXP() | SELECT SQRT(area) FROM plots |
LOG(), LN(), LOG10() | SELECT LOG10(views) FROM posts |
MOD(), RAND() | SELECT MOD(id, 10) FROM users |
GREATEST(), LEAST() | SELECT GREATEST(a, b, c) FROM scores |
Date Functions
| Function | Example |
|---|---|
NOW(), CURRENT_TIMESTAMP() | SELECT NOW() as current_time |
EXTRACT(part FROM date) | SELECT EXTRACT(YEAR FROM created_at) FROM users |
DATEDIFF(), DATE_ADD(), DATE_SUB() | SELECT DATEDIFF(NOW(), created_at) FROM users |
DATE_FORMAT(), TO_CHAR() | SELECT DATE_FORMAT(date, '%Y-%m') FROM events |
DATE_TRUNC() | SELECT DATE_TRUNC('month', created_at) FROM users |
Conditional Functions
| Function | Example |
|---|---|
CASE WHEN | SELECT CASE WHEN age > 18 THEN 'adult' ELSE 'minor' END FROM users |
COALESCE() | SELECT COALESCE(nickname, name) FROM users |
NULLIF() | SELECT NULLIF(status, 'unknown') FROM users |
IF(), IIF() | SELECT IF(active, 'yes', 'no') FROM users |
Type Conversion
| Function | Example |
|---|---|
CAST(expr AS type) | SELECT CAST(price AS INTEGER) FROM products |
CONVERT(expr, type) | SELECT CONVERT(date_str, DATE) FROM events |
Parameterized Queries
Prevent SQL injection with parameterized placeholders. Both MySQL-style (?) and PostgreSQL-style ($1) are supported on all backends.
// MySQL-style placeholders
const users = await db.sql(
'SELECT * FROM users WHERE email = ? AND status = ? LIMIT 10',
{ params: ['alice@example.com', 'active'] }
);
// PostgreSQL-style placeholders
const orders = await db.sql(
'SELECT * FROM orders WHERE user_id = $1 AND total > $2 LIMIT 20',
{ params: [42, 100] }
);
Transactions in SQL Mode
Execute multi-statement transactions via SQL or the programmatic API. Supported on all backends except Elasticsearch.
// SQL transaction block
await db.sql(`
BEGIN;
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
COMMIT;
`);
// Programmatic transaction (auto-commits on success, rolls back on error)
await db.withTransaction(async (tx) => {
await tx.insertOne('users', { name: 'Alice' });
await tx.updateOne('accounts', { userId: 1 }, { $inc: { balance: -100 } });
});
| Backend | Transaction Support |
|---|---|
| MongoDB | ✓ Session-based (requires replica set) |
| PostgreSQL | ✓ Native |
| MySQL | ✓ Native |
| MSSQL | ✓ Native |
| SQLite | ✓ Native |
| Elasticsearch | — Not supported |
Explain Mode
Pass { explain: true } to see the full execution plan alongside results. Useful for debugging and understanding how SQL translates to MongoDB pipelines.
const result = await db.sql(`
SELECT department, AVG(salary) as avg_sal
FROM employees
GROUP BY department
HAVING AVG(salary) > 50000
LIMIT 10
`, { explain: true });
// result.data → [{ department: 'Engineering', avg_sal: 95000 }, ...]
// result.plan → {
// phases: 1,
// dependencies: [],
// pipelines: [{ collection: 'employees', stages: [
// { $group: { _id: '$department', avg_sal: { $avg: '$salary' } } },
// { $match: { avg_sal: { $gt: 50000 } } },
// { $limit: 10 }
// ]}],
// parallel: false,
// durationMs: 23
// }
Explain Output Fields
| Field | Type | Description |
|---|---|---|
phases | number | 1 (no dependencies) or 2 (has subqueries/CTEs) |
dependencies | array | Resolved subqueries and CTEs with their pipelines |
pipelines | array | MongoDB aggregation stages that were executed |
parallel | boolean | True if FULL OUTER JOIN or independent pipelines ran in parallel |
durationMs | number | Total execution time in milliseconds |
Guardrails in SQL Mode
The same safety guardrails that protect MongoDB-style queries also apply to SQL Mode.
| Guardrail | Blocked Query | Fix |
|---|---|---|
| Unbounded SELECT | SELECT * FROM users | Add LIMIT |
| Empty-filter UPDATE | UPDATE users SET active = false | Add WHERE clause |
| Empty-filter DELETE | DELETE FROM logs | Add WHERE clause |
| NULL comparison | WHERE field = NULL | Use IS NULL |
// This is blocked (no LIMIT):
await db.sql('SELECT * FROM users');
// Error: LIMIT_REQUIRED — Add a LIMIT clause
// This is allowed:
await db.sql('SELECT * FROM users LIMIT 100');
Cross-Dialect Normalization
StrictDB automatically normalizes dialect-specific SQL so queries work across all backends. Write in the dialect you know — StrictDB handles the rest.
MSSQL Normalizations
| MSSQL Syntax | Normalized To |
|---|---|
SELECT TOP 10 * | SELECT * ... LIMIT 10 |
DATEPART(year, date) | EXTRACT(YEAR FROM date) |
GETDATE() | NOW() |
ISNULL(a, b) | COALESCE(a, b) |
LEN(x) | LENGTH(x) |
CHARINDEX(sub, str) | POSITION(sub IN str) |
MySQL Normalizations
| MySQL Syntax | Normalized To |
|---|---|
IFNULL(a, b) | COALESCE(a, b) |
IF(cond, a, b) | CASE WHEN cond THEN a ELSE b END |
TOP and GETDATE(), and it will execute correctly on MongoDB, PostgreSQL, or any other backend.
db.aggregate(collection, pipeline, options?)
Run a raw aggregation pipeline directly against MongoDB. Bypasses filter translation — you write the pipeline yourself. On SQL and Elasticsearch backends, this method throws UNSUPPORTED_OPERATION.
// Raw MongoDB aggregate pipeline
const stats = await db.aggregate('orders', [
{ $match: { status: 'completed' } },
{
$group: {
_id: '$userId',
totalSpent: { $sum: '$amount' },
orderCount: { $sum: 1 }
}
},
{ $sort: { totalSpent: -1 } },
{ $limit: 10 }
]);
// → [{ _id: 'user-1', totalSpent: 450, orderCount: 9 }, ...]
// With explain
const plan = await db.aggregate('orders', pipeline, { explain: true });
aggregate() when you want to write raw MongoDB pipelines. Use sql() when you want to write SQL and have StrictDB generate the pipeline for you.
db.bulkWrite(collection, operations, options?)
Execute multiple write operations in a single round-trip. More efficient than calling individual methods in a loop. Returns a combined OperationReceipt.
const receipt = await db.bulkWrite('products', [
{
insertOne: {
doc: { sku: 'WIDGET-1', name: 'Widget', price: 9.99, stock: 100 }
}
},
{
updateOne: {
filter: { sku: 'GADGET-5' },
update: { $inc: { stock: -10 } }
}
},
{
updateMany: {
filter: { price: { $lt: 1 } },
update: { $set: { discontinued: true } }
}
},
{
deleteOne: {
filter: { sku: 'OLD-SKU' }
}
}
]);
// receipt.insertedCount → 1
// receipt.modifiedCount → number of rows/docs updated
// receipt.deletedCount → 1
// receipt.duration → total ms for the batch
bulkWrite() Supported Operations
| Operation | Fields | Description |
|---|---|---|
insertOne | { doc } | Insert a single document |
updateOne | { filter, update, upsert? } | Update first matching document |
updateMany | { filter, update } | Update all matching documents |
deleteOne | { filter } | Delete first matching document |
deleteMany | { filter } | Delete all matching documents |
deleteMany and updateMany inside bulkWrite are still blocked unless you pass confirm: 'DELETE_ALL' or 'UPDATE_ALL' in the operation.
Operation Receipts
Every write operation returns a structured receipt:
{
operation: 'insertOne', // operation name
collection: 'users', // target collection/table
success: true, // did it succeed?
matchedCount: 0, // documents matched by filter
modifiedCount: 0, // documents actually modified
insertedCount: 1, // documents inserted
insertedId: 'abc123', // ID of inserted doc (insertOne only)
insertedIds: ['a', 'b'], // IDs inserted (insertMany only)
upsertedId: 'xyz789', // ID of upserted doc (upsert only)
deletedCount: 0, // documents deleted
duration: 12, // execution time in ms
backend: 'sql' // 'mongo' | 'sql' | 'elastic'
}
Transactions
Full transaction support across all backends:
await db.withTransaction(async (tx) => {
// All operations inside use the same transaction
await tx.insertOne('orders', {
userId: 'user-1',
item: 'widget',
qty: 5
});
await tx.updateOne('inventory',
{ sku: 'W1' },
{ $inc: { stock: -5 } }
);
// If anything throws, entire transaction rolls back
});
| Backend | Transaction Support | Notes |
|---|---|---|
| MongoDB | Yes | Requires replica set |
| PostgreSQL | Yes | BEGIN / COMMIT / ROLLBACK |
| MySQL | Yes | START TRANSACTION / COMMIT / ROLLBACK |
| MSSQL | Yes | BEGIN TRANSACTION / COMMIT / ROLLBACK |
| SQLite | Yes | BEGIN / COMMIT / ROLLBACK |
| Elasticsearch | No | Throws UNSUPPORTED_OPERATION |
Batch Operations
const receipt = await db.batch([
{ operation: 'insertOne', collection: 'orders',
doc: { item: 'widget', qty: 5, userId: 'u1' } },
{ operation: 'updateOne', collection: 'inventory',
filter: { sku: 'W1' },
update: { $inc: { stock: -5 } } },
{ operation: 'deleteOne', collection: 'cart_items',
filter: { userId: 'u1', sku: 'W1' } }
]);
Filter Reference — Comparison Operators
StrictDB uses MongoDB-style filters across all backends. Filters are automatically translated to SQL WHERE clauses or Elasticsearch Query DSL.
$eq — Equality
Implicit when you pass a value directly. These are identical:
{ role: 'admin' } // shorthand
{ role: { $eq: 'admin' } } // explicit
"role" = $1 -- params: ['admin']{ term: { role: 'admin' } }$ne — Not Equal
{ status: { $ne: 'banned' } }
"status" != $1{ bool: { must_not: { term: { status: 'banned' } } } }$gt, $gte, $lt, $lte — Range
{ age: { $gte: 18, $lt: 65 } }
"age" >= $1 AND "age" < $2
-- params: [18, 65]{ range: { age: { gte: 18, lt: 65 } } }null — Null Equality
{ email: null }
"email" IS NULL{ bool: { must_not: { exists: { field: 'email' } } } }Filter Reference — Array & Pattern Operators
$in — Match Any Value in Array
{ role: { $in: ['admin', 'mod'] } }
"role" IN ($1, $2)
-- params: ['admin', 'mod']{ terms: { role: ['admin', 'mod'] } }$nin — Not In Array
{ role: { $nin: ['banned', 'suspended'] } }
"role" NOT IN ($1, $2){ bool: { must_not: { terms: { role: ['banned', 'suspended'] } } } }$exists — Field Existence
{ email: { $exists: true } } // field IS NOT NULL
{ email: { $exists: false } } // field IS NULL
"email" IS NOT NULL{ exists: { field: 'email' } }$regex — Pattern Matching
{ name: { $regex: '^Tim' } }
| Dialect | SQL Output |
|---|---|
| PostgreSQL | "name" ~ $1 |
| MySQL | "name" REGEXP ? |
| MSSQL | "name" LIKE @p1 |
| SQLite | "name" LIKE ? |
| Elasticsearch | { regexp: { name: '^Tim' } } |
$size — Array Length
{ tags: { $size: 3 } }
| Dialect | Translation |
|---|---|
| PostgreSQL | jsonb_array_length("tags") = $1 |
| MySQL | JSON_LENGTH("tags") = ? |
| SQLite | json_array_length("tags") = ? |
| Elasticsearch | Script query on array length |
Filter Reference — Logical Operators
$and — All Conditions Must Match
{ $and: [{ age: { $gte: 18 } }, { role: 'admin' }] }
(("age" >= $1) AND ("role" = $2)){ bool: { must: [
{ range: { age: { gte: 18 } } },
{ term: { role: 'admin' } }
] } }Multiple top-level fields are implicitly $and:
{ role: 'admin', age: { $gte: 18 } }
// SQL: "role" = $1 AND "age" >= $2
$or — Any Condition Matches
{ $or: [{ role: 'admin' }, { role: 'mod' }] }
(("role" = $1) OR ("role" = $2)){ bool: { should: [
{ term: { role: 'admin' } },
{ term: { role: 'mod' } }
], minimum_should_match: 1 } }$nor — No Conditions Match
{ $nor: [{ role: 'banned' }, { status: 'inactive' }] }
NOT (("role" = $1) OR ("status" = $2)){ bool: { must_not: [
{ term: { role: 'banned' } },
{ term: { status: 'inactive' } }
] } }$not — Negate a Condition
{ age: { $not: { $gt: 18 } } }
NOT ("age" > $1){ bool: { must_not: [
{ range: { age: { gt: 18 } } }
] } }Full SQL Translation Examples
Complete before/after examples showing how MongoDB-style filters become native SQL across all four dialects.
Real-World Query: Active Admin Users
// StrictDB filter (works on any backend)
const filter = {
status: 'active',
age: { $gte: 18 },
role: { $in: ['admin', 'mod'] }
};
| Dialect | Generated SQL |
|---|---|
| PostgreSQL | WHERE "status" = $1 AND "age" >= $2 AND "role" IN ($3, $4) |
| MySQL | WHERE "status" = ? AND "age" >= ? AND "role" IN (?, ?) |
| MSSQL | WHERE "status" = @p1 AND "age" >= @p2 AND "role" IN (@p3, @p4) |
| SQLite | WHERE "status" = ? AND "age" >= ? AND "role" IN (?, ?) |
Complex Nested Query
{
$or: [
{ role: 'admin' },
{ $and: [
{ age: { $gte: 18 } },
{ status: { $ne: 'banned' } },
{ email: { $exists: true } }
]}
]
}
-- PostgreSQL output:
(("role" = $1) OR (("age" >= $2) AND ("status" != $3) AND ("email" IS NOT NULL)))
Full SELECT Statements
| Operation | SQL Output (PostgreSQL) |
|---|---|
| Basic select | SELECT * FROM "users" WHERE "role" = $1 |
| With sort + limit | SELECT * FROM "users" ORDER BY "name" ASC LIMIT 50 |
| With pagination | SELECT * FROM "users" LIMIT 10 OFFSET 20 |
| Insert | INSERT INTO "users" ("name", "age") VALUES ($1, $2) |
| Update | UPDATE "users" SET "name" = $1 WHERE "id" = $2 |
| Delete | DELETE FROM "users" WHERE "id" = $1 |
| Count | SELECT COUNT(*) as count FROM "users" WHERE "role" = $1 |
Elasticsearch Translation Examples
Complex Query to ES Query DSL
// StrictDB filter
{
status: 'active',
age: { $gte: 18, $lt: 65 },
role: { $in: ['admin', 'mod'] },
email: { $exists: true }
}
// Elasticsearch Query DSL output:
{
bool: {
must: [
{ term: { status: 'active' } },
{ range: { age: { gte: 18, lt: 65 } } },
{ terms: { role: ['admin', 'mod'] } },
{ exists: { field: 'email' } }
]
}
}
Update Operators — $set, $inc, $unset
$set — Set Field Values
{ $set: { name: 'Bob', role: 'admin' } }
SET "name" = $1, "role" = $2ctx._source.name = params.set_name;
ctx._source.role = params.set_role$inc — Increment Numeric Fields
{ $inc: { loginCount: 1, score: -5 } }
SET "loginCount" = "loginCount" + $1,
"score" = "score" + $2ctx._source.loginCount += params.inc_loginCount;
ctx._source.score += params.inc_score$unset — Remove Fields
{ $unset: { tempField: true } }
SET "tempField" = NULLctx._source.remove('tempField')Combined Update
{ $set: { name: 'Bob' }, $inc: { count: 1 } }
// SQL: SET "name" = $1, "count" = "count" + $2
// ES: ctx._source.name = params.set_name; ctx._source.count += params.inc_count
Update Operators — $push, $pull
$push — Append to Array
{ $push: { tags: 'new-tag' } }
| Backend | Behavior |
|---|---|
| MongoDB | Native $push |
| Elasticsearch | ctx._source.tags.add(params.push_tags) |
| SQL | JSON array manipulation (backend-specific) |
$pull — Remove from Array
{ $pull: { tags: 'old-tag' } }
| Backend | Behavior |
|---|---|
| MongoDB | Native $pull |
| Elasticsearch | ctx._source.tags.removeIf(e -> e == params.pull_tags) |
| SQL | JSON array manipulation (backend-specific) |
SQL Dialects — Parameter Styles
| Dialect | Placeholder | Example |
|---|---|---|
| PostgreSQL | $1, $2, $3... | WHERE "age" = $1 AND "role" = $2 |
| MySQL | ?, ?, ?... | WHERE "age" = ? AND "role" = ? |
| SQLite | ?, ?, ?... | WHERE "age" = ? AND "role" = ? |
| MSSQL | @p1, @p2, @p3... | WHERE "age" = @p1 AND "role" = @p2 |
SQL Dialects — Pagination
Each SQL dialect handles LIMIT/OFFSET differently. StrictDB generates the correct syntax automatically.
| Scenario | PG / MySQL / SQLite | MSSQL |
|---|---|---|
| Limit only | LIMIT 10 | SELECT TOP(10) * |
| Skip + Limit | LIMIT 10 OFFSET 20 | ORDER BY (SELECT NULL) OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
| Skip only | OFFSET 20 | ORDER BY (SELECT NULL) OFFSET 20 ROWS |
| Sort + Skip + Limit | ORDER BY "name" ASC LIMIT 10 OFFSET 5 | ORDER BY "name" ASC OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY |
ORDER BY (SELECT NULL) as a fallback.
SQL Dialects — Single-Row Semantics
updateOne and deleteOne enforce exactly one row across all dialects:
| Dialect | updateOne Technique | deleteOne Technique |
|---|---|---|
| PostgreSQL | Subquery with ctid | Subquery with ctid |
| MySQL | LIMIT 1 | LIMIT 1 |
| SQLite | Subquery with rowid | LIMIT 1 |
| MSSQL | UPDATE TOP(1) | DELETE TOP(1) |
SQL Dialects — Upsert
Pass upsert: true to updateOne. StrictDB uses an UPDATE-then-INSERT pattern that works identically across all SQL dialects:
- Try
UPDATE(limited to 1 row) - If zero rows matched,
INSERTwith filter equality fields merged with$setfields
await db.updateOne('users',
{ email: 'test@test.com' }, // filter
{ $set: { name: 'Test', role: 'user' } }, // update
true // upsert
);
// If no match → INSERT INTO "users" ("email", "name", "role") VALUES ($1, $2, $3)
SQL Dialects — Projection
Inclusion (select specific columns):
{ projection: { name: 1, email: 1 } }
// → SELECT "name", "email" FROM "users" ...
Exclusion (hide columns — SQL doesn't natively support this, so StrictDB uses post-processing):
{ projection: { password: 0, secret: 0 } }
// → SELECT * FROM "users" ... (then strips password, secret from results)
Schema & Validation — Zod Registration
import { z } from 'zod';
const userSchema = z.object({
email: z.string().max(255),
name: z.string(),
age: z.number().int().optional(),
role: z.enum(['admin', 'user', 'mod']),
active: z.boolean(),
createdAt: z.date(),
tags: z.array(z.string()).optional(),
metadata: z.object({}).optional(),
});
db.registerCollection({ name: 'users', schema: userSchema });
// Auto-create tables/indexes
await db.ensureCollections();
await db.ensureIndexes();
Schema — SQL DDL Generation
StrictDB introspects Zod schemas and generates CREATE TABLE statements for each dialect:
PostgreSQL
CREATE TABLE IF NOT EXISTS "users" (
"email" VARCHAR(255) NOT NULL,
"name" TEXT NOT NULL,
"age" INTEGER,
"role" TEXT NOT NULL CHECK(role IN ('admin', 'user', 'mod')),
"active" BOOLEAN NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL,
"tags" JSONB,
"metadata" JSONB
)
Type Mapping
| Zod Type | PG | MySQL | MSSQL | SQLite |
|---|---|---|---|---|
z.string().max(N) | VARCHAR(N) | VARCHAR(N) | NVARCHAR(N) | TEXT |
z.string() | TEXT | TEXT | NVARCHAR(MAX) | TEXT |
z.number().int() | INTEGER | INT | INT | INTEGER |
z.number() | DOUBLE PRECISION | DOUBLE | FLOAT | REAL |
z.boolean() | BOOLEAN | TINYINT(1) | BIT | INTEGER |
z.date() | TIMESTAMPTZ | DATETIME | DATETIME2 | TEXT |
z.enum([...]) | TEXT + CHECK | TEXT | NVARCHAR(255) | TEXT |
z.array(...) | JSONB | JSON | NVARCHAR(MAX) | TEXT |
Schema — Elasticsearch Mapping Generation
| Zod Type | ES Mapping |
|---|---|
z.string().max(256) | { type: 'keyword' } |
z.string() | { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } } |
z.number().int() | { type: 'integer' } |
z.number() | { type: 'double' } |
z.boolean() | { type: 'boolean' } |
z.date() | { type: 'date' } |
z.enum([...]) | { type: 'keyword' } |
z.array(...) | { type: 'nested' } |
Schema — Runtime Validation
With schema: true in config, all writes are validated before execution:
const db = await StrictDB.create({ uri, schema: true });
try {
await db.insertOne('users', { name: '', email: 'bad' });
} catch (err) {
err.code // 'VALIDATION_ERROR'
err.fix // "Field 'name' must be at least 1 character. Field 'email' must be a valid email."
}
Guardrails
Enabled by default. These are hard blocks, not warnings.
| Operation | Blocked When | Guardrail | Override |
|---|---|---|---|
deleteMany({}) | Empty filter | emptyFilter | { confirm: 'DELETE_ALL' } + non-empty filter |
updateMany({}) | Empty filter | emptyFilter | { confirm: 'UPDATE_ALL' } + non-empty filter |
deleteOne({}) | Empty filter | emptyFilter | Always requires a non-empty filter |
queryMany() | No limit | limitRequired | Include { limit: N } |
SELECT * FROM t | No LIMIT | limitRequired | Add LIMIT clause |
DELETE FROM t | No WHERE | emptyFilter | Add WHERE clause |
WHERE x = NULL | = NULL comparison | nullComparison | Use IS NULL |
Error code: GUARDRAIL_BLOCKED. Emits guardrail-blocked event.
Granular Configuration
Pass true/false to enable/disable all guardrails, or an object for granular control:
const db = await StrictDB.create({
uri,
guardrails: {
limitRequired: false, // allow unbounded queries
emptyFilter: true, // still block empty-filter deletes/updates
nullComparison: true, // still block = NULL in SQL mode
}
});
| Option | Type | Default | Description |
|---|---|---|---|
limitRequired | boolean | true | Block queryMany() without limit and SELECT without LIMIT |
emptyFilter | boolean | true | Block deleteMany, deleteOne, updateMany with empty filters, and DELETE/UPDATE without WHERE |
nullComparison | boolean | true | Block = NULL and != NULL in SQL mode (should use IS NULL / IS NOT NULL) |
Sanitization
Enabled by default. Prevents injection and validates field names.
- SQL: Column names validated against registered schema whitelist
- Elasticsearch: Internal fields (
_id,_source,_score,_index,_type,_routing,_meta,_field_names,_ignored,_seq_no,_primary_term) blocked from user queries - ES index names: Wildcards, commas, spaces, system indices (
.or-prefix) blocked - Regex: Patterns validated for catastrophic backtracking (ReDoS). Nested quantifiers and patterns >1000 chars rejected
Custom Sanitize Rules
const db = await StrictDB.create({
uri,
sanitizeRules: [
// Trim all string fields
{ transform: (v) => typeof v === 'string' ? v.trim() : v },
// Lowercase emails
{ field: 'email', transform: (v) => v.toLowerCase() },
// Strip HTML from specific fields
{ field: ['name', 'bio'],
transform: (v) => v.replace(/<[^>]*>/g, '') },
// Apply to ALL string fields
{ field: '*',
transform: (v) => typeof v === 'string' ? v.trim() : v },
]
});
SanitizeRule Interface
| Property | Type | Description |
|---|---|---|
field | string | string[] | '*' | Which fields to apply to. Omit or '*' for all fields. |
transform | (value, field, collection) => value | Transform function. Receives value, field name, and collection name. |
AI Integration — Why AI-First?
Traditional database drivers give AI agents raw access with no guardrails. StrictDB provides four tools that make AI database interactions safe and self-correcting:
| Problem | Without StrictDB | With StrictDB |
|---|---|---|
| AI hallucinates column names | Query fails with cryptic error | describe() returns real schema |
| AI writes bad query | Executes and corrupts data | validate() catches before execution |
| AI doesn't understand translation | No visibility into native query | explain() shows exact SQL/ES |
| AI gets an error | Parses stack trace (unreliable) | Reads .fix field (exact action) |
AI runs DELETE FROM users | All data deleted | Blocked by guardrails |
describe(collection) — Schema Discovery
const schema = await db.describe('users');
// {
// name: 'users',
// backend: 'sql',
// fields: [
// { name: 'id', type: 'integer', required: true },
// { name: 'email', type: 'string', required: true },
// { name: 'role', type: 'string', required: true, enum: ['admin', 'user', 'mod'] },
// { name: 'age', type: 'integer', required: false },
// ],
// indexes: [{ fields: { email: 1 }, unique: true }],
// documentCount: 12847,
// exampleFilter: { role: 'admin' }
// }
validate(collection, operation) — Dry-Run
const check = await db.validate('users', {
filter: { rol: 'admin' }, // typo!
doc: { email: 'test@test.com' }
});
// { valid: false, errors: [
// { field: 'rol', message: 'Unknown field', expected: 'role' }
// ]}
explain(collection, operation) — Query Translation
const plan = await db.explain('users', {
filter: { role: 'admin', age: { $gte: 18 } },
sort: { createdAt: -1 },
limit: 50
});
// {
// backend: 'sql',
// native: 'SELECT * FROM "users" WHERE "role" = $1 AND "age" >= $2 ORDER BY "createdAt" DESC LIMIT 50'
// }
Self-Correcting Errors
Every StrictDBError includes a .fix field — a plain-English corrective action:
try {
await db.insertOne('users', { email: 'alice@example.com' });
} catch (err) {
err.code // 'DUPLICATE_KEY'
err.fix // 'Use updateOne() instead or check with queryOne() first'
err.retryable // false
err.backend // 'sql'
err.collection // 'users'
err.operation // 'insertOne'
}
Fuzzy Collection Matching
await db.queryOne('usres', { id: 1 }); // typo!
// Error fix: 'Did you mean "users"? Registered collections: users, orders, products'
Method Suggestions
// If an AI tries to use MongoDB-native method names:
db.find(...) // → fix: 'Use queryMany() instead'
db.findOne(...) // → fix: 'Use queryOne() instead'
db.bulkWrite(...) // → fix: 'Use batch() instead'
MCP Server — Setup
# Install
npm install -g strictdb-mcp
# Run (reads STRICTDB_URI from environment)
STRICTDB_URI="postgresql://user:pass@localhost:5432/mydb" npx strictdb-mcp
MCP Server — All 14 Tools
| Tool | Type | Parameters |
|---|---|---|
strictdb_describe | discover | collection |
strictdb_validate | validate | collection, filter?, update?, doc? |
strictdb_explain | explain | collection, filter?, sort?, limit? |
strictdb_query_one | read | collection, filter, sort? |
strictdb_query_many | read | collection, filter, sort?, limit (required), skip? |
strictdb_count | read | collection, filter? |
strictdb_insert_one | write | collection, doc |
strictdb_insert_many | write | collection, docs[] |
strictdb_update_one | write | collection, filter, update {$set?, $inc?, $unset?}, upsert? |
strictdb_update_many | write | collection, filter, update {$set?, $inc?, $unset?} |
strictdb_delete_one | write | collection, filter |
strictdb_delete_many | write | collection, filter |
strictdb_batch | batch | operations[] |
strictdb_status | util | none |
MCP Server — Claude Desktop Configuration
{
"mcpServers": {
"strictdb": {
"command": "npx",
"args": ["strictdb-mcp"],
"env": {
"STRICTDB_URI": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}
Claude Code Configuration
{
"mcpServers": {
"strictdb": {
"type": "stdio",
"command": "npx",
"args": ["strictdb-mcp"],
"env": {
"STRICTDB_URI": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}
Events
db.on('connected', ({ backend, dbName, label }) => { ... });
db.on('disconnected', ({ backend, reason, timestamp }) => { ... });
db.on('reconnecting', ({ backend, attempt, maxAttempts, delayMs }) => { ... });
db.on('reconnected', ({ backend, attempt, downtimeMs }) => { ... });
db.on('error', ({ code, message, fix, backend }) => { ... });
db.on('operation', ({ collection, operation, durationMs, receipt }) => { ... });
db.on('slow-query', ({ collection, operation, durationMs, threshold }) => { ... });
db.on('guardrail-blocked', ({ collection, operation, reason }) => { ... });
db.on('pool-status', ({ active, idle, waiting, max }) => { ... });
db.on('shutdown', ({ exitCode }) => { ... });
Reconnection
Exponential backoff with jitter to prevent thundering herd:
// Delay formula:
// baseDelay = initialDelayMs * (backoffMultiplier ^ (attempt - 1))
// cappedDelay = min(baseDelay, maxDelayMs)
// actualDelay = cappedDelay * (0.75 + random * 0.5) ← jitter ±25%
// Example with defaults (1s initial, 2x multiplier, 30s max):
// Attempt 1: ~1s (750ms – 1250ms)
// Attempt 2: ~2s (1500ms – 2500ms)
// Attempt 3: ~4s (3000ms – 5000ms)
// Attempt 4: ~8s (6000ms – 10000ms)
// Attempt 5: ~16s (12000ms – 20000ms)
// Attempt 6+: ~30s (capped at maxDelayMs)
Auto Timestamps
// Enable with defaults (created_at, updated_at)
const db = await StrictDB.create({ uri, timestamps: true });
// Or customize field names
const db = await StrictDB.create({
uri,
timestamps: { createdAt: 'born', updatedAt: 'modified' }
});
- Insert: Both
createdAtandupdatedAtinjected - Update: Only
updatedAtinjected (never overwritescreatedAt) - User values preserved: If you explicitly set a timestamp field, StrictDB won't overwrite it
- Immutable: Original documents are never mutated — new objects returned
Escape Hatch — db.raw()
Access the underlying native driver when you need backend-specific features:
const native = db.raw();
// MongoDB → returns MongoClient
// PostgreSQL → returns pg.Pool
// MySQL → returns mysql2 connection
// MSSQL → returns mssql connection pool
// SQLite → returns better-sqlite3 Database
// Elasticsearch → returns @elastic/elasticsearch Client
raw() bypass guardrails, sanitization, receipts, and event logging. Use only when StrictDB's unified API doesn't cover your use case.
Error Code Reference
| Code | Retryable | .fix Message |
|---|---|---|
CONNECTION_FAILED | Yes | Check connection URI and ensure database server is running |
CONNECTION_LOST | Yes | Connection dropped — auto-reconnect will attempt recovery |
AUTHENTICATION_FAILED | No | Check username/password in connection URI |
TIMEOUT | Yes | Add a filter to narrow results, add an index, or increase timeout |
POOL_EXHAUSTED | Yes | Connection pool full — reduce concurrency or increase pool size |
DUPLICATE_KEY | No | Use updateOne() instead or check with queryOne() first |
VALIDATION_ERROR | No | Document doesn't match schema — see error details for field-specific fixes |
COLLECTION_NOT_FOUND | No | Did you mean "X"? Run ensureCollections() to create tables |
QUERY_ERROR | No | Check field names and filter operators |
GUARDRAIL_BLOCKED | No | Provide a non-empty filter or use confirm option |
UNKNOWN_OPERATOR | No | Use supported filter syntax: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex |
SCHEMA_MISMATCH | No | Document structure doesn't match registered schema |
UNSUPPORTED_OPERATION | No | Operation not available for this backend (e.g., transactions on Elasticsearch) |
INTERNAL_ERROR | No | Unexpected backend error — see originalError for details |
TypeScript Types
import type {
Backend, // 'mongo' | 'sql' | 'elastic'
Driver, // 'mongodb' | 'pg' | 'mysql2' | 'mssql' | 'sqlite' | 'elasticsearch'
SqlDialect, // 'pg' | 'mysql2' | 'mssql' | 'sqlite'
StrictFilter, // MongoDB-style filter object
UpdateOperators, // { $set?, $inc?, $unset?, $push?, $pull? }
QueryOptions, // { sort?, limit?, skip?, projection? }
LookupOptions, // { match, lookup, unwind?, sort?, limit? }
OperationReceipt, // Write operation result
StrictDBConfig, // create() configuration
StrictDBEvents, // Typed event map
SanitizeRule, // Custom sanitization rule
ReconnectConfig, // Reconnection options
IndexDefinition, // Index schema
CollectionSchema, // Collection + Zod schema registration
StrictDBError, // Error class with .fix, .code, .retryable
} from 'strictdb';
Using Generics
interface User {
email: string;
name: string;
role: 'admin' | 'user' | 'mod';
age?: number;
}
// Fully typed — result is User | null
const user = await db.queryOne<User>('users', { email: 'alice@example.com' });
// Filter autocomplete works with the generic
const admins = await db.queryMany<User>('users',
{ role: 'admin' },
{ sort: { name: 1 }, limit: 50 }
);