Package reference
Mirrors the package README (single source). Install @basaltkit/audit-sqlite v2.0.1 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/audit-sqlite
Durable, SQLite-backed implementation of the @basaltkit/auditAuditStore — the append-only audit trail — built on Node's built-in node:sqlite. Zero external dependencies.
Swap it in for the in-memory store and the trail survives a restart — no ORM, no migration tool, no service. The single-node reference backend; the production (Postgres/MySQL) counterpart is @basaltkit/audit-prisma.
pnpm add @basaltkit/audit-sqlite # peer: @basaltkit/audit> Requires Node 22.5+. Stable and flag-free on Node 24; on 22.x run with > --experimental-sqlite.
Use it
import { auditPlugin } from '@basaltkit/audit'
import { sqliteAuditStore } from '@basaltkit/audit-sqlite'
const a = sqliteAuditStore('./data/audit.db') // ':memory:' by default
const app = await createApp({
plugins: [auditPlugin({ store: a.store })],
}).boot()SqliteAuditStore is also exported and takes a DatabaseSync, so it can share a handle with the other *-sqlite stores. openAuditDatabase() and migrate() are exported too.
Verifiable trail and request context
The store supports everything @basaltkit/audit can record:
auditPlugin({ store: a.store, integrity: 'hash-chain', requestContext: true })- Hash chain —
seq,prev_hash,hashand achainkey ('t:<tenantId>'or'@system') are stored per row, with a unique index on(chain, seq): two processes appending to the same file cannot fork a chain — the loser getsAuditChainConflictErrorandAuditretries on the new head.audit.verify()/basalt audit:verifyread the chain back inseqorder. Thehashcolumn stores the self-describing hash as written (v2:hmac-sha256:<keyId>:<hex>), so key ids and key rotation need no schema change. - Request context —
ipanduser_agentcolumns. - Automatic migration —
migrate()(run byopenAuditDatabase()/sqliteAuditStore()) adds the new columns and the index to an existing database withALTER TABLE. Rows written before keep NULLs and are reported byverify()as unchained (legacy), never as broken. - Rows outside the chain —
readUnchained()finds every row of a tenant that is not in its chain: a row withchain IS NULLbut aseq, or achainname that is not the tenant's, is always reported byverify()(unverified,ok: false); a seq-less row only when it was written after the chain began.trail({ chainedOnly: true })leaves them out, andverifyAll()reports a chain name that maps to no tenant asunknown-chain.verifyAll()also visits tenants whose rows are all outside a chain, found with oneSELECT DISTINCT tenant_id(auditTenants()) rather than a read of the whole trail. - SQLite has no roles to
REVOKE UPDATE, DELETEfrom: protect the database file with filesystem permissions (only the app user can write it) and back it up; for a keyed chain seeintegrity: { mode: 'hash-chain', key }in the@basaltkit/auditREADME.
Notes
- Append-only by contract — one
audit_entriestable, no update or delete. - Queries return newest-first with the same filters as the in-memory store:
tenantId,actorId,since,chainedOnly, and the event wildcard (auth:**).limitalways counts only pattern-matched rows. Every filter is type-checked (assertAuditQuery) even when the store is called directly: a non-stringtenantId/actorId/eventor a non-integerlimitthrows aTypeError. - The
payloadis stored as JSON text and round-trips unchanged. node:sqliteis synchronous; the methods stayasyncto honor the contract.- Query pushdown. Every exact filter — tenant, actor,
since, and an event name with no wildcard — plus thelimitgo into the database (take/LIMIT). Only a wildcard pattern still needs matching in code, and then rows are read in bounded 500-row pages that stop as soon as the limit is satisfied, so alimit: 50query never materialises the whole trail. A pattern containing.is deliberately not pushed down:patternMatchestreats.and:as interchangeable separators, so an equality would missa:bfora.b.
License
MIT