Advanced database¶
On top of the base database guide, the SDK ships four opt-in
pieces for more demanding domains — a faithful port of db.tenant / db.audit
/ db.outbox / db.user_model from tempest-fastapi-sdk.
Nullable columns are required on insert
In tempest-db-js, a column without .notNull() and without
.default(...) is required on create — you pass null explicitly. That's
why the examples below pass lastLoginAt: null, context: null, etc.
1. Multi-tenant without leaks¶
TenantScopedRepository binds a tenantId at construction and injects it into
every read and write — you can't forget a WHERE tenant_id = ?.
import { BaseModel, TenantScopedRepository, column, tableNameFor } from "tempest-express-sdk";
class OrderModel extends BaseModel {
static tablename = tableNameFor("OrderModel"); // "order"
tenantId = column.uuid().notNull();
total = column.numeric(12, 2).notNull();
}
const repo = new TenantScopedRepository(OrderModel, session, currentTenantId);
await repo.list(); // WHERE tenant_id = currentTenantId
await repo.create({ total: "10.00" }); // tenant_id stamped (don't pass it)
await repo.getById(otherTenantOrderId); // throws RecordNotFound across tenants
Reads (list/first/count/exists/getById/paginate) filter by tenant;
create/createMany stamp it; update/delete only touch the tenant's rows,
and update never moves a row to another tenant. Custom field:
new TenantScopedRepository<typeof M, "orgId">(M, session, id, "orgId").
2. Transactional outbox¶
Write the domain change and the event to publish in the same transaction
(into a BaseOutboxModel table). The event is never lost, even if the broker is
down. OutboxRelay polls and publishes with at-least-once delivery.
import {
BaseOutboxModel,
BaseRepository,
OutboxRelay,
createEngine,
} from "tempest-express-sdk";
class OutboxModel extends BaseOutboxModel {
static tablename = "outbox";
}
// inside the domain write's transaction:
await engine.transaction(async (tx) => {
const orders = new OrderRepository(tx);
const outbox = new BaseRepository(OutboxModel, tx);
const order = await orders.create({ tenantId, total: "10.00" });
await outbox.create({
topic: "order.created",
payload: { id: order.id },
sentAt: null,
lastError: null,
});
});
// in a background worker:
const relay = new OutboxRelay(
new BaseRepository(OutboxModel, engine.session()),
async (topic, payload) => broker.publish(topic, payload), // your broker
);
await relay.run(1000); // drains every 1s; relay.stop() ends it
Failures increment attempts and reschedule with backoff (availableAt); after
maxAttempts the row becomes failed with the lastError.
3. Audit trail¶
BaseAuditLogModel is an append-only log of who changed what. snapshot freezes
a row and diffSnapshots computes the before/after diff — what the service
writes.
import {
AuditAction,
BaseAuditLogModel,
BaseRepository,
diffSnapshots,
snapshot,
} from "tempest-express-sdk";
class AuditLogModel extends BaseAuditLogModel {
static tablename = "audit_log";
}
// in the service, when updating a user:
const before = snapshot(user, ["hashedPassword"]); // exclude sensitive fields
await users.update({ id: user.id }, { name: "Ana Maria" });
const after = snapshot(await users.getById(user.id), ["hashedPassword"]);
await new BaseRepository(AuditLogModel, session).create({
entity: "UserModel",
entityId: user.id,
action: AuditAction.UPDATE,
actor: actorId,
changes: diffSnapshots(before, after), // { name: { before, after } }
context: { requestId },
});
4. Base user and token models¶
Opt-in starting points that already carry the common columns. Extend, set
tablename, add the rest:
import {
BaseUserModel,
BaseUserRefreshTokenModel,
BaseUserTokenModel,
UserTokenPurpose,
column,
tableNameFor,
} from "tempest-express-sdk";
class UserModel extends BaseUserModel {
static tablename = tableNameFor("UserModel"); // "user"
name = column.text().notNull();
}
// BaseUserModel: email, hashedPassword, isAdmin, lastLoginAt
class UserTokenModel extends BaseUserTokenModel {
static tablename = "user_token";
}
// BaseUserTokenModel: userId, tokenHash, purpose, expiresAt, usedAt, payload
class RefreshTokenModel extends BaseUserRefreshTokenModel {
static tablename = "refresh_token";
}
// BaseUserRefreshTokenModel: userId, tokenHash, familyId, expiresAt, usedAt, revokedAt
UserTokenPurpose carries the canonical values (ACTIVATION, PASSWORD_RESET,
EMAIL_VERIFICATION, EMAIL_CHANGE). Always store the token hash.
5. Slow-query logging¶
tempest-db-js exposes SQL+params in onQuery but no duration — so timing
happens at the driver layer. wrapWithSlowQueryLog wraps an AsyncDriver and
logs every statement over a threshold (including inside transactions):
import { AsyncEngine, NodeSqliteDriver, wrapWithSlowQueryLog } from "tempest-express-sdk";
const sync = NodeSqliteDriver.open("app.db");
const timed = wrapWithSlowQueryLog(
{ execute: (s, p) => Promise.resolve(sync.execute(s, p)), close: async () => sync.close() },
{ thresholdMs: 200 }, // logParameters only in dev (may carry PII)
);
const engine = new AsyncEngine(timed, "sqlite");
Applies to engines you construct
createEngine does not yet expose driver injection, so the wrap applies when
you build the AsyncEngine from a driver (SQLite, or the same pattern as
tests). A timing hook in createEngine is an upstream item.
6. Database backup¶
backupDatabase detects the dialect from the URL: pg_dump for Postgres (needs
the binary on PATH), a file copy for SQLite.
import { backupDatabase } from "tempest-express-sdk";
await backupDatabase("sqlite://./app.db", "./backups/app-2026-07-06.db");
await backupDatabase(
"postgresql://app@localhost/app",
"./backups/app.dump",
{ pgDumpArgs: ["-Fc", "--no-owner"] },
);
In-memory SQLite throws (nothing to copy).
Recap¶
TenantScopedRepository— forget-proof per-tenant isolation.BaseOutboxModel+OutboxRelay— events published atomically with the write.BaseAuditLogModel+snapshot/diffSnapshots— who changed what.BaseUserModel/BaseUserTokenModel/BaseUserRefreshTokenModel— opt-in base models.wrapWithSlowQueryLog— logs slow queries;backupDatabase— per-dialect backup. ✅