Reorient @alkdev/storage around a single SQLite database host with Honker
for pub/sub, event streams, and task queues. PostgreSQL is removed as a
target (ADR-038), eliminating dual schema maintenance and infrastructure
complexity. Honker provides DB + pubsub + queues in one .db file (ADR-039).
Add system/tenant DB model (ADR-040): identity tables in system.db, all
graph data in tenant-{orgId}.db files. Identity tables move from the hub
into storage (ADR-041). Scoping columns (ownerId, projectId) added to
graphs table (ADR-042). Graph types get scope (system/tenant/user) to
protect infrastructure schemas (ADR-043).
Define Drizzle-Honker session adapter (ADR-044): ~100-line adapter enabling
Drizzle typed queries and Honker pubsub/queue on a single connection with
transactional consistency.
Resolve OQ-03, OQ-04, OQ-19, OQ-21, OQ-22, OQ-23, OQ-24. Add new
open questions OQ-26 through OQ-29 for Honker integration specifics.
New docs: honker-integration.md (adapter, event patterns, migration).
Scrub all PG/jsonb/libsql references from existing spec docs.
13 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-05-31 |
Honker Integration
How @alkdev/storage integrates with Honker for SQLite database operations, transactional pub/sub, durable event streams, and task queues.
Purpose
Honker provides SQLite with built-in pub/sub, event streams, work queues,
advisory locks, and cron scheduling — all within the same .db file. This
eliminates the need for separate PostgreSQL and Redis deployments and solves
the dual-write problem between data writes and event publishing.
This document specifies:
- The Drizzle-Honker session adapter architecture (ADR-044)
- The event-driven patterns enabled by Honker
- Transaction coordination between Drizzle and Honker
- The system/tenant DB model for Honker-managed databases
- Migration from the previous PostgreSQL + Redis architecture
The Adapter
Architecture
Consumer code
├── db.select().from(graphs)... → Drizzle query builder
│ └── HonkerPreparedQuery.run/all/get/values
│ └── honkerDb.query(sql, params) / tx.execute(sql, params)
├── db.$client.notify('channel', p) → Honker ephemeral pub/sub
├── db.$client.queue('jobs') → Honker work queue
├── db.$client.stream('events') → Honker durable stream
└── db.$client.listen('channel') → Honker listener
The adapter wraps Honker's Database inside Drizzle's SQLiteSession<'sync'>
contract. Drizzle handles typed queries; Honker handles pubsub/queue/stream
primitives. They share the same SQLite connection and transaction context.
Adapter Components
| Component | Extends | Purpose |
|---|---|---|
HonkerSQLiteSession |
SQLiteSession<'sync'> |
Routes Drizzle queries to honkerDb.query() / tx.execute() |
HonkerPreparedQuery |
SQLitePreparedQuery |
Implements run(), all(), get(), values() via Honker |
HonkerSQLiteTransaction |
SQLiteTransaction<'sync'> |
Drizzle transaction with $honkerTx for Honker access |
drizzle(client, config) |
— | Factory: creates BaseSQLiteDatabase from Honker client |
Key Integration Points
Reading — all() and get() delegate to honkerDb.query(sql, params),
which uses the reader pool. Honker returns Array<Record<string, any>>
(row objects). The adapter converts to Drizzle's column format via
mapResultRow().
Writing — run() delegates to tx.execute(sql, params) which acquires the
writer slot. For standalone writes (no explicit transaction), the adapter
creates a temporary transaction, executes, and commits.
Transactions — Drizzle's callback-based db.transaction((tx) => ...) wraps
honker's explicit begin/commit/rollback:
transaction(callback) {
const honkerTx = this.client.transaction();
const txSession = new HonkerTxSession(honkerTx, ...);
const drizzleTx = new HonkerSQLiteTransaction('sync', ..., txSession, ...);
try {
const result = callback(drizzleTx);
honkerTx.commit();
return result;
} catch (e) {
honkerTx.rollback();
throw e;
}
}
Nested transactions — Savepoints via raw SQL:
// Inside a Drizzle transaction callback
drizzleTx.transaction((nestedTx) => {
// Adapter fires: tx.execute('SAVEPOINT sp0')
// ... queries ...
// Adapter fires: tx.execute('RELEASE sp0')
});
Accessing Honker from Drizzle
The adapter exposes two access points:
db.$client— The HonkerDatabaseinstance, for use outside transactions:
const db = drizzle(honkerDb, { schema });
db.$client.notify('graph:created', { graphId });
tx.$honkerTx— The HonkerTransactioninstance, for use within Drizzle transaction callbacks:
db.transaction((tx) => {
tx.insert(schema.nodes).values({ graphId, key: 'call-1', attributes: {} }).run();
tx.$honkerTx.notify('nodes:created', { graphId, key: 'call-1' });
// Both commit atomically — data + notification in one transaction
});
Known Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
No lastInsertRowid from execute() |
run() needs extra SELECT last_insert_rowid() |
Small Rust PR to honker-node |
| No prepared statement handle at JS level | Every query re-enters napi | Rust-side prepare_cached amortizes |
| Object rows only, no raw arrays | values() must convert Object.values() |
Column order preserved from SQLite |
| Drizzle internal API dependency | Adapter imports from drizzle-orm/sqlite-core/session |
Stable across minor versions |
Event-Driven Patterns
Ephemeral Notifications
Fire-and-forget events for signaling. Like PostgreSQL's NOTIFY/LISTEN.
// After creating a graph, notify listeners
db.transaction((tx) => {
tx.insert(schema.graphs).values({ name: 'session-1', status: 'draft' }).run();
tx.$honkerTx.notify('graphs:created', { name: 'session-1' });
});
// Listener (separate process or same process)
const listener = db.$client.listen('graphs:created');
for await (const event of listener) {
console.log('New graph:', event.payload);
}
Use cases: Cache invalidation signals, UI update push, spoke reconnection triggers.
Durable Event Streams
Per-consumer replay-safe delivery. Each consumer tracks its own offset. Crash recovery replays from the last saved offset.
// Publish call protocol events to a durable stream
const callStream = db.$client.stream('call-protocol');
callStream.publish({ type: 'call.requested', requestId: 'req-1', operationId: 'op-call' });
// Consumer: dashboard replays from its last offset
const subscription = callStream.subscribe('dashboard');
for await (const event of subscription) {
// event has: id, payload, timestamp
updateDashboard(event.payload);
// offset auto-saved every 1000 events or 1 second
}
Use cases: Call protocol event persistence, audit trail replay, session event replay, cross-process coordination.
Transactional Outbox
Combine data writes with side-effect delivery. A queue job is enqueued in the same transaction as the data write, guaranteeing the side effect will eventually be processed.
// Insert a node AND schedule a downstream processing job
db.transaction((tx) => {
tx.insert(schema.nodes).values({ graphId, key: 'task-1', attributes: taskData }).run();
// Outbox: enqueue a job that will be processed after commit
const q = db.$client.queue('task-processing');
q.enqueueTx(tx.$honkerTx, { taskKey: 'task-1', priority: 5 });
});
Use cases: ACL evaluation after permission change, encrypted data key rotation, schema migration jobs, retention cleanup.
Work Queues
At-least-once background job processing with retries, priority, delayed execution, claim expiration, and dead-letter handling.
// Enqueue a background job
const q = db.$client.queue('key-rotation');
q.enqueue({ keyVersion: 2, batchSize: 100 }, { priority: 3 });
// Worker: claim and process
const waker = q.claimWaker('rotator-1');
for await (const job of waker) {
await rotateKeys(job.payload);
job.ack();
}
Use cases: Key rotation, rate limit sweep, retention cleanup, schema migration across tenant DBs, notification pruning.
Advisory Locks
Named locks with TTL for leader election and exclusive access.
// Only one hub instance runs the scheduler
const lock = db.$client.tryLock('scheduler-leader', 'hub-instance-1', 30);
if (lock) {
startScheduler(db.$client.scheduler());
// Renew periodically
setInterval(() => lock.heartbeat(), 10000);
}
Cron Scheduling
Time-triggered operations using 5-field cron or @every intervals.
const scheduler = db.$client.scheduler();
scheduler.add('retention-cleanup', '0 3 * * *', {
queue: 'maintenance',
payload: { task: 'cleanup-expired-graphs', olderThanDays: 90 },
});
System DB + Tenant DB with Honker
Each database — system and tenant — is a separate Honker-managed SQLite file with its own pubsub channels, streams, and queues.
System DB Event Channels
| Channel | Direction | Payload |
|---|---|---|
account:created |
Ephemeral | { accountId, email, accessLevel } |
account:updated |
Ephemeral | { accountId, changes } |
org:created |
Ephemeral | { orgId, slug, ownerId } |
org:member_added |
Ephemeral | { orgId, accountId, membershipLevel } |
auth:key_verified |
Ephemeral | { keyId, ownerId } |
System DB Streams
| Stream | Consumers | Purpose |
|---|---|---|
audit-events |
Compliance, monitoring | Append-only audit trail |
System DB Queues
| Queue | Worker | Purpose |
|---|---|---|
key-management |
Hub key service | API key rotation, expired key cleanup |
account-maintenance |
Hub account service | Deactivation processing, org transfer |
Tenant DB Event Channels
| Channel | Direction | Payload |
|---|---|---|
graph:created |
Ephemeral | { graphId, graphTypeId, ownerId } |
graph:updated |
Ephemeral | { graphId, changes } |
nodes:created |
Ephemeral | { graphId, keys[] } |
acl:delegation_changed |
Ephemeral | { principalId, agentId } |
Tenant DB Streams
| Stream | Consumers | Purpose |
|---|---|---|
call-protocol |
Flowgraph projector, observability | Call protocol event replay |
session-events |
Session manager, audit | Session lifecycle events |
Tenant DB Queues
| Queue | Worker | Purpose |
|---|---|---|
acl-evaluation |
ACL evaluator | Scope recalculation after delegation change |
secret-rotation |
Key service | Re-encryption with new key version |
graph-maintenance |
Maintenance service | Graph archival, retention cleanup |
schema-migration |
Migration service | Data migration for schema version bumps |
Cross-DB Coordination
The system DB and tenant DBs are separate files. The hub mediates between them at the application layer.
Authentication Flow
1. Request → API key hash → system.db: SELECT * FROM api_keys WHERE keyHash = ?
2. system.db: Verify key → resolve ownerId → accounts row → org memberships
3. Open tenant-{orgId}.db → check ACL graph for operation access
4. Execute operation on tenant DB
Cross-Tenant Operations
If a user in org A delegates to a user in org B, both tenant DBs are involved:
1. hub opens tenant-a.db and tenant-b.db
2. tenant-a.db: Read PrincipalNode for delegator
3. tenant-b.db: Create DelegatesEdge in ACL graph
4. hub ensures both writes succeed (application-level two-phase commit or
best-effort with reconciliation)
Cross-tenant operations are expected to be rare. For v1, best-effort with reconciliation is acceptable. A formal two-phase commit across SQLite files would require a coordinator — the hub fills this role.
Migration from PostgreSQL + Redis
What Changes
| Component | Before | After |
|---|---|---|
| Hub database | PostgreSQL (drizzle-orm/node-postgres) |
SQLite via Honker (drizzle-orm/sqlite-core + adapter) |
| Hub pub/sub | @alkdev/pubsub Redis transport |
Honker notify()/stream() within SQLite |
| Hub task queue | Custom or none | Honker queue() |
| Hub leader election | Redis SET NX or none |
Honker tryLock() |
| Hub scheduling | Cron daemon or none | Honker scheduler() |
| Hub connection | Pool → drizzle(pool, { schema }) |
Honker open() → drizzle(honkerDb, { schema }) |
| Spoke database | SQLite via @libsql/client |
SQLite via Honker (same engine, richer features) |
| Schema | pgTable (hub) + sqliteTable (spoke) |
sqliteTable only |
| Testing | PostgreSQL + Redis containers | In-memory SQLite (:memory:) via Honker |
What Doesn't Change
- The metagraph Module system (CallGraph, SecretGraph, AclGraph Modules)
- Bridge functions (
moduleToDbSchema,validateNode,validateEdge) - Crypto utility (encrypt, decrypt,
EncryptedDataSchema) - TypeBox schemas from drizzlebox
- The Drizzle query builder API (same
.select(),.insert(),.update(),.delete()calls) - The
@alkdev/operationscall protocol (events are now published via Honker streams instead of Redis)
Migration Path
- Add identity tables to
src/sqlite/tables/(from hub) - Add scoping columns to
graphstable - Add scope column to
graph_typestable - Remove
actorstable andpg/directory - Implement adapter in
src/sqlite/adapter.ts - Split client factory into
createSystemDatabase()/createTenantDatabase() - Update hub to consume new storage API instead of its own table definitions
- Migrate hub data from PostgreSQL to SQLite (export → import script)
Design Decisions
| ADR | Decision | Status |
|---|---|---|
| 039 | Honker as SQLite extension and transport | Accepted |
| 044 | Drizzle-Honker session adapter | Accepted |
Open Questions
- OQ-26: Can Honker fully replace
@alkdev/pubsub's Redis transport for single-node deployments? - OQ-27: How are schema migrations applied across all tenant DBs?
- OQ-28: How does cross-tenant delegation work with separate DBs?
- OQ-29: Should the adapter be published as a standalone
drizzle-honkernpm package for community use?
References
- Honker README:
/workspace/honker/README.md - Honker Node binding:
/workspace/honker/packages/honker-node/ - Drizzle SQLite session:
/workspace/drizzle-orm/src/sqlite-core/session.ts - ADR-039: Honker as SQLite extension
- ADR-044: Drizzle-Honker session adapter
- ADR-040: System DB + tenant DB