Skip to content

Querying & Export

Better Audit exposes a fluent, immutable query builder for reading audit logs and a streaming export engine for generating CSV and JSON downloads. Both are powered by the same underlying adapter — the ORM package handles the SQL, the builder handles what to ask for.

audit.query() returns an AuditQueryBuilder. Each method call returns a new builder instance — builders are safe to fork, share, and reuse.

src/routes/audit.ts
import { audit } from "../audit.js";
// Fetch up to 1000 entries (the default limit)
const result = await audit.query().list();
console.log(result.entries); // AuditLog[]
console.log(result.nextCursor); // string | undefined — present when more pages exist

All filter methods are chainable. Within a single method, multiple values use OR semantics (entry matches any). Across different methods, conditions use AND semantics (entry must satisfy all).

const result = await audit.query()
.resource("users") // table: users
.actor("user-42", "user-99") // actorId is user-42 OR user-99
.operation("DELETE") // operation is DELETE
.severity("high", "critical") // severity is high OR critical
.list();

.resource(tableName, recordId?) narrows to a specific table, and optionally a specific row:

// All events for the users table
audit.query().resource("users")
// All events for a specific user record
audit.query().resource("users", "user-42")

.actor(...ids) accepts one or more actor IDs (OR semantics, deduplicates):

audit.query().actor("user-42")
audit.query().actor("user-42", "user-99", "system:job")

.operation(...ops) accepts "INSERT", "UPDATE", and "DELETE" (OR semantics):

// Only destructive events
audit.query().operation("DELETE")
// Mutations — inserts and updates
audit.query().operation("INSERT", "UPDATE")

.severity(...levels) accepts "low", "medium", "high", "critical" (OR semantics):

// Flag for review — high and critical only
audit.query().severity("high", "critical")

Entries without a severity are excluded when a severity filter is set.

.compliance(...tags) uses AND semantics — entries must carry all listed tags:

// Must be tagged with both gdpr AND soc2
audit.query().compliance("gdpr", "soc2")
// Any single tag
audit.query().compliance("pci")

.since() and .until() accept either a Date or a duration string. Duration strings are validated eagerly and resolved at query time so each call gets a fresh “now”:

UnitExampleMeaning
h"4h"Last 4 hours
d"30d"Last 30 days
w"2w"Last 2 weeks
m"3m"Last 3 months
y"1y"Last 1 year
// Relative: last 24 hours
audit.query().since("24h")
// Absolute: from a specific date
audit.query().since(new Date("2025-01-01"))
// Range
audit.query()
.since("30d")
.until(new Date("2025-06-01"))

.search(text) applies a full-text filter against entry fields. Maximum 500 characters. Adapters use parameterized queries — never raw string interpolation:

audit.query().search("profile updated")

Results are cursor-paginated. When nextCursor is present on the result, pass it to .after() for the next page:

// First page
const page1 = await audit.query()
.resource("users")
.limit(50)
.list();
// Next page
if (page1.nextCursor) {
const page2 = await audit.query()
.resource("users")
.limit(50)
.after(page1.nextCursor)
.list();
}

.limit(n) must be greater than zero and cannot exceed maxQueryLimit (default 1000, configurable in betterAudit()). When no limit is set, maxQueryLimit is used as the default:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
// Raise the cap at configuration time
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
maxQueryLimit: 5000,
});

.order("asc" | "desc") controls the result order. When omitted, the adapter chooses its default (typically newest-first):

// Oldest first — useful for chronological reports
audit.query().since("7d").order("asc").list()

All methods are chainable and each returns a new builder, so you can fork a base query and specialise it:

const base = audit.query()
.since("30d")
.severity("high", "critical");
// Two specialisations sharing the same time + severity filter
const deletions = await base.operation("DELETE").list();
const userEvents = await base.resource("users").list();

audit.export() streams rows in cursor-paginated batches (default 500 per round-trip) so memory stays flat regardless of export size. It requires queryLogs on the database adapter — the same requirement as audit.query().

Pass output: "string" to buffer the full output in memory. The result includes the string in data:

const result = await audit.export({
format: "csv",
output: "string",
});
console.log(result.rowCount); // number of rows exported
console.log(result.data); // full CSV string

For JSON, choose between "ndjson" (one JSON object per line, default) and "array" (a single pretty-printed JSON array):

// NDJSON — stream-friendly, one object per line
const ndjson = await audit.export({
format: "json",
jsonStyle: "ndjson",
output: "string",
});
// JSON array — pretty-printed, easier to read
const jsonArray = await audit.export({
format: "json",
jsonStyle: "array",
output: "string",
});

Pass a WritableStream<string> to write directly without buffering. The export engine applies backpressure per row.

The Web Streams WritableStream constructor is available natively in Node 22+, Bun, and Deno. Use it to wrap any sink:

import { createWriteStream } from "node:fs";
const fileStream = createWriteStream("audit-export.csv");
const encoder = new TextEncoder();
const output = new WritableStream<string>({
write(chunk) {
fileStream.write(encoder.encode(chunk));
},
close() {
fileStream.end();
},
});
await audit.export({ format: "csv", output });

audit.exportResponse() returns a standard Response with streaming body and correct headers — Content-Type, Content-Disposition (with auto-generated filename), and Cache-Control: no-cache. The export starts immediately; the response is returned before all rows are read.

src/routes/audit.ts
import { Hono } from "hono";
import { audit } from "../audit.js";
const app = new Hono();
// Ensure your auth middleware is mounted before this route
app.get("/audit/export", (c) => {
return audit.exportResponse({ format: "csv" });
});

The default filename is audit-export-YYYY-MM-DD with the appropriate extension (.csv, .json, or .ndjson). Override with the filename option:

audit.exportResponse({
format: "csv",
filename: "compliance-report-q1-2025",
});
// Content-Disposition: attachment; filename="compliance-report-q1-2025.csv"

Pass a query builder to either export method to filter what gets exported. The builder’s filters and sort order are respected; its limit is used as a total cap:

// Export only the last 90 days of critical user deletions
const query = audit.query()
.resource("users")
.operation("DELETE")
.severity("critical")
.since("90d");
// In-memory
const result = await audit.export({
format: "csv",
query,
output: "string",
});
// As a download response
const response = audit.exportResponse({
format: "csv",
query,
});
MethodDescription
.resource(table, recordId?)Filter by table name and optional record ID. Last-write-wins.
.actor(...ids)Filter by actor IDs. OR semantics. Deduplicates.
.operation(...ops)Filter by operation type (INSERT, UPDATE, DELETE). OR semantics.
.severity(...levels)Filter by severity (low, medium, high, critical). OR semantics.
.compliance(...tags)Filter by compliance tags. AND semantics — entry must have all listed tags.
.since(Date | string)Filter entries created after this point. Duration strings accepted.
.until(Date | string)Filter entries created before this point. Duration strings accepted.
.search(text)Full-text search filter. Max 500 characters.
.limit(n)Max entries to return. Must be ≤ maxQueryLimit.
.after(cursor)Resume from a previous page using nextCursor.
.order("asc" | "desc")Result sort direction. Defaults to newest-first (desc) when omitted.
.list()Execute the query and return Promise<AuditQueryResult>.
.toSpec()Return the query spec without executing. Useful for tests.
OptionTypeDefaultDescription
format"csv" | "json"requiredOutput format.
outputWritableStream<string> | "string"requiredWrite target.
queryAuditQueryBuilderall entriesFilter and limit applied to the export.
batchSizenumber500Rows per database round-trip.
jsonStyle"ndjson" | "array""ndjson"JSON output style. Ignored for CSV.
csvDelimiterstring","Single character CSV delimiter.

Same options as ExportOptions minus output, plus:

OptionTypeDefaultDescription
filenamestring"audit-export-YYYY-MM-DD"Filename stem for Content-Disposition (no extension).
FieldTypeDescription
rowCountnumberTotal rows written.
datastring | undefinedFull output string. Present only when output is "string".
  • Enrichment — add labels, severity, and compliance tags to entries
  • ConfigurationmaxQueryLimit, retention policy, and lifecycle hooks
  • Adapters — ORM adapter setup and the queryLogs requirement