- Update architecture docs to reflect pivot from @libsql/client to Honker - Fold @alkdev/drizzlebox Phase 0 into src/sqlite/utils/ (ADR-046) - Add HonkerEventTarget adapter for pubsub TypedEventTarget (ADR-047) - Replace hand-written CRUD with OperationSpec generation (ADR-048) - Resolved OQ-26: Honker replaces Redis for single-node pub/sub (POC validated) - Updated OQ-17, OQ-18, OQ-19 for OperationSpec repository surface - Added OQ-30 (composite event target), OQ-31 (consumer naming), OQ-32 (Drizzle Kit) - POC results: adapter buildable, same-process pub/sub works, transactional outbox semantics confirmed, concurrent listeners/streams work - Research doc at docs/research/pivot-honker-sqlite-adapter.md
23 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-01 |
SQLite Host
The SQLite database host for @alkdev/storage. Uses Drizzle ORM with Honker
for database operations, pub/sub, event streams, and task queues. TypeBox
schemas are generated from Drizzle table definitions via src/sqlite/utils/
(folded from @alkdev/dbtype/@alkdev/drizzlebox, ADR-046).
Overview
The SQLite host provides:
- Metagraph tables — 6 tables for graph types, node types, edge types, graphs, nodes, and edges (ADR-002)
- Identity tables — accounts, organizations, organization_members, api_keys, audit_logs for multi-tenant authentication and authorization (ADR-041)
- Drizzle relations for the relational query API
- TypeBox schemas generated from Drizzle tables (select/insert
validation) via
src/sqlite/utils/(folded from @alkdev/drizzlebox, ADR-046) - Drizzle-Honker adapter — thin session adapter for Honker integration (ADR-044, POC validated)
- HonkerEventTarget — pubsub
TypedEventTargeton Honker primitives (ADR-047, POC validated) - Client factories —
createSystemDatabase(client)andcreateTenantDatabase(client)for the system/tenant DB model (ADR-040)
Package Structure
src/sqlite/
├── tables/
│ ├── common.ts # commonCols
│ ├── identity/
│ │ ├── accounts.ts # accounts table + select/insert schemas
│ │ ├── organizations.ts # organizations table + select/insert schemas
│ │ ├── organization_members.ts # org membership + select/insert schemas
│ │ ├── api_keys.ts # API keys (keypal) + select/insert schemas
│ │ ├── audit_logs.ts # audit trail + select/insert schemas
│ │ └── index.ts # barrel re-export
│ ├── metagraph/
│ │ ├── graphTypes.ts # graph_types table + select/insert schemas
│ │ ├── nodeTypes.ts # node_types table + select/insert schemas
│ │ ├── edgeTypes.ts # edge_types table + select/insert schemas
│ │ ├── graphs.ts # graphs table + select/insert schemas
│ │ ├── nodes.ts # nodes table + select/insert schemas
│ │ ├── edges.ts # edges table + select/insert schemas
│ │ └── index.ts # barrel re-export
│ └── index.ts # barrel re-export
├── utils/ # folded from @alkdev/dbtype Phase 0 (ADR-046)
│ ├── schema.ts # createSelectSchema, createInsertSchema, createUpdateSchema
│ ├── column.ts # Column→TypeBox mappings (SQLite-only dispatch)
│ ├── types.ts # Public + internal TypeScript interfaces
│ ├── constants.ts # Integer range constants
│ └── utils.ts # isColumnType, isWithEnum, type helpers
├── relations.ts # Drizzle relational mappings
├── adapter.ts # Drizzle-Honker session adapter
├── event-target.ts # HonkerEventTarget (pubsub TypedEventTarget on Honker)
├── schema.ts # re-exports all tables + relations
└── client.ts # createSystemDatabase(), createTenantDatabase()
Common Columns
All tables share these columns:
{
id: text("id").primaryKey(),
metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown>>().default({}),
createdAt: integer("created_at", { mode: "timestamp" })
.default(sql`(strftime('%s', 'now'))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.default(sql`(strftime('%s', 'now'))`)
.notNull(),
}
idis a consumer-generated UUID text PK (no$defaultFn)metadatais an extension namespace following_subsystem.keyconventioncreatedAt/updatedAtare Unix epoch integers with timestamp mode- No
$onUpdate— consumers must setupdatedAtexplicitly
Metagraph Tables
graph_types
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | Consumer-generated UUID |
| metadata | text (JSON) | default {} |
Extension namespace |
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| name | text | not null, unique | Graph type name (e.g., "call-graph", "acl") |
| description | text | default "" |
Human-readable description |
| config | text (JSON) | not null | GraphConfig — directed/undirected/mixed, multi, self-loops |
| version | integer | not null, default 1 | Breaking schema version (ADR-029) |
| scope | text | not null, default "system" |
system / tenant / user (ADR-043) |
The scope column (ADR-043) controls who can create and modify graph type
definitions. System-scoped types (acl, call-graph) are seeded at setup time
and cannot be modified through the repository API.
node_types
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | |
| metadata | text (JSON) | default {} |
|
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| graphTypeId | text | not null, FK → graphTypes.id (cascade) | Parent graph type |
| name | text | not null | Node type name (e.g., "call", "account") |
| description | text | default "" |
|
| schema | text (JSON) | not null | TypeBox schema for node attributes |
Unique constraint: (graphTypeId, name) — node type names are unique within
a graph type.
edge_types
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | |
| metadata | text (JSON) | default {} |
|
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| graphTypeId | text | not null, FK → graphTypes.id (cascade) | Parent graph type |
| name | text | not null | Edge type name (e.g., "triggered", "can_read") |
| description | text | default "" |
|
| schema | text (JSON) | not null | TypeBox schema for edge attributes |
| allowedSourceTypes | text (JSON) | default [] |
Node type names valid at source endpoint |
| allowedTargetTypes | text (JSON) | default [] |
Node type names valid at target endpoint |
Unique constraint: (graphTypeId, name).
Empty array semantics: [] means "no restriction" — any node type is valid.
graphs
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | |
| metadata | text (JSON) | default {} |
|
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| graphTypeId | text | FK → graphTypes.id (set null) | Set null on graph type deletion (orphan graph) |
| name | text | not null | Graph instance name |
| description | text | default "" |
|
| status | text | not null, enum: active, archived, draft |
Default: draft |
| ownerId | text | nullable | Logical reference to accounts.id (ADR-042) |
| projectId | text | nullable | Logical reference to project identity (ADR-042) |
Scoping columns (ADR-042): ownerId and projectId are logical references
to entities in the system DB (accounts, projects). No FK constraint because the
referenced tables live in a different database file. The consumer enforces
referential integrity at the application layer.
No orgId column — the tenant DB file itself IS the org scope (ADR-040).
Indexes: idx_graphs_owner_id on (ownerId), idx_graphs_project_id on
(projectId), idx_graphs_owner_id_project_id on (ownerId, projectId).
On graphTypeId set null: Orphan graphs cannot validate their node/edge
types against a missing type definition. The application should prevent graph
type deletion if active graphs reference it.
nodes
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | |
| metadata | text (JSON) | default {} |
|
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| graphId | text | not null, FK → graphs.id (cascade) | Parent graph |
| key | text | not null | Consumer-defined identity within the graph |
| attributes | text (JSON) | not null, default {} |
Node attributes validated by node type schema |
Unique constraint: (graphId, key) — node keys are unique within a graph.
No nodeTypeId column: ADR-020.
edges
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | text | PK | |
| metadata | text (JSON) | default {} |
|
| createdAt | integer (timestamp) | not null, default now |
|
| updatedAt | integer (timestamp) | not null, default now |
|
| graphId | text | not null, FK → graphs.id (cascade) | Parent graph |
| key | text | Consumer-defined identity (null for anonymous edges) | |
| sourceNodeKey | text | not null | Source node key within the graph |
| targetNodeKey | text | not null | Target node key within the graph |
| attributes | text (JSON) | not null, default {} |
Edge attributes validated by edge type schema |
| undirected | integer (boolean) | default false | Treat as undirected regardless of graph type |
Unique constraint: (graphId, key).
Foreign keys: sourceNodeKey and targetNodeKey reference
(nodes.graphId, nodes.key) with cascade delete (ADR-022).
Identity Tables
Identity tables live in the system DB (ADR-040, ADR-041). They provide multi-tenant authentication and authorization infrastructure. These tables are derived from the hub's existing identity tables; the schemas are aligned but simplified for the storage package's scope.
accounts
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| text NOT NULL UNIQUE | Unique identifier | |
| displayName | text | Display name |
| accessLevel | text NOT NULL DEFAULT user |
admin, user, service |
| status | text NOT NULL DEFAULT active |
active, suspended, deactivated |
Indexes: unq_accounts_email UNIQUE on (email).
organizations
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| name | text NOT NULL UNIQUE | Organization name |
| slug | text NOT NULL UNIQUE | URL-friendly identifier |
| ownerId | text NOT NULL | Logical reference to accounts.id |
Indexes: unq_organizations_name UNIQUE on (name), unq_organizations_slug UNIQUE on (slug).
organization_members
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| orgId | text NOT NULL | FK → organizations.id (cascade) |
| accountId | text NOT NULL | FK → accounts.id (cascade) |
| membershipLevel | text NOT NULL | owner, admin, member |
Unique constraint: (orgId, accountId).
Indexes: idx_org_members_account_id on (accountId).
This table is the authoritative source for org membership (ADR-045). The ACL
graph's BelongsToEdge is derived from it — when membership changes, the
consumer writes the SQL row first, then creates or removes the ACL edge.
api_keys
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| ownerId | text NOT NULL | Logical reference to accounts.id |
| keyHash | text NOT NULL UNIQUE | SHA-256 hash (never stores raw key) |
| name | text | Human-readable key label |
| enabled | integer NOT NULL DEFAULT 1 | Disable without revoking |
| expiresAt | integer (timestamp) | When the key expires (null = never) |
| revokedAt | integer (timestamp) | When revoked (null = active) |
Indexes: unq_api_keys_key_hash UNIQUE on (keyHash), idx_api_keys_owner_id on (ownerId).
Keypal scope data is stored in metadata (metadata.scopes, metadata.resources).
The hub provides a HubKeyStorage adapter that reads/writes this table to
implement keypal's Storage interface.
audit_logs
| Column | Type | Notes |
|---|---|---|
| commonCols | — | id, metadata, createdAt, updatedAt |
| action | text NOT NULL | created, revoked, rotated, login, access_denied |
| ownerId | text NOT NULL | Logical reference to accounts.id |
| keyId | text | Logical reference to api_keys.id (nullable) |
| orgId | text | Logical reference to organizations.id (nullable) |
| details | text (JSON) | Action-specific context |
Indexes: idx_audit_logs_owner_id on (ownerId), idx_audit_logs_action on (action), idx_audit_logs_created_at on (createdAt).
Relations
System DB Relations
- organizations → organization_members: one-to-many
- accounts → organization_members: one-to-many
Tenant DB Relations
- graphTypes → nodeTypes: one-to-many
- graphTypes → edgeTypes: one-to-many
- graphTypes → graphs: one-to-many
- graphs → nodes: one-to-many
- graphs → edges: one-to-many
- nodes → outgoing edges (sourceNode): one-to-many
- nodes → incoming edges (targetNode): one-to-many
Client Factories
createSystemDatabase(client)
Creates a Drizzle database instance with the identity schema (accounts, organizations, organization_members, api_keys, audit_logs) attached.
import { createSystemDatabase } from "@alkdev/storage/sqlite";
import { open } from "@russellthehipp/honker-node";
const honkerClient = open("system.db");
const db = createSystemDatabase(honkerClient);
// Drizzle typed queries
const admins = db.select().from(accounts).where(eq(accounts.accessLevel, "admin"));
// Honker features on the same connection
db.$client.notify("account:created", { accountId: "user-1" });
createTenantDatabase(client)
Creates a Drizzle database instance with the metagraph schema (graph_types, node_types, edge_types, graphs, nodes, edges) attached.
import { createTenantDatabase } from "@alkdev/storage/sqlite";
import { open } from "@russellthehipp/honker-node";
const honkerClient = open("tenant-acme.db");
const db = createTenantDatabase(honkerClient);
// Drizzle typed queries
const activeGraphs = db.select().from(graphs).where(eq(graphs.status, "active"));
// Transactional: insert node + notify in one commit
db.transaction((tx) => {
tx.insert(nodes).values({ graphId, key: "call-1", attributes: {} }).run();
tx.$honkerTx.notify("nodes:created", { graphId, key: "call-1" });
});
Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| 038 | SQLite-first, PG removed | Single database host |
| 039 | Honker as SQLite extension | DB + pub/sub + queues in one file |
| 040 | System DB + tenant DB | Identity in system.db, graphs in tenant-{orgId}.db |
| 041 | Identity tables in storage | accounts, organizations, api_keys, audit_logs |
| 042 | Scoping columns on graphs | ownerId, projectId on graphs table |
| 043 | Graph type scope | system / tenant / user scope on graph_types |
| 044 | Drizzle-Honker adapter | ~100-line session adapter, POC validated |
| 045 | org_members authoritative | SQL table is source of truth; BelongsToEdge is derived |
| 046 | Fold drizzlebox as utils | SQLite-only column mappings in src/sqlite/utils/ |
| 047 | HonkerEventTarget | pubsub TypedEventTarget on Honker |
| 048 | OperationSpecs as repo surface | Table-defined operation contracts |
| 019 | JSON text for schema columns | SQLite uses text with JSON mode |
| 020 | No nodeTypeId on nodes | Node type enforced at application layer |
| 022 | Composite FKs for node refs | Edges reference (graphId, sourceNodeKey) |
| 008 | Common columns pattern | id, metadata, createdAt, updatedAt |
Removed: actors Table
The actors table is removed per ADR-035. ACTOR_TYPE is replaced by the
IdentityType enum in the AclGraph Module. Identity data lives in the
accounts table (system DB) and PrincipalNode in ACL graph instances
(tenant DB).
Removed: PostgreSQL Porting Notes
PostgreSQL is no longer a target (ADR-038). All porting notes from the previous version of this document are obsolete. The single database host is SQLite via Honker.
Concurrency Model
Honker opens databases in WAL mode with a bounded reader pool and single writer slot. This handles the expected concurrency for the hub use case:
- Reader pool: Up to
maxReaders(default 4) concurrent read connections.db.$client.query()uses the pool automatically. - Writer slot: Single exclusive writer, acquired by
transaction(). If the slot is occupied, subsequenttransaction()calls block until released. - Write timeout: Honker sets
busy_timeout=5000by default. Configurable atopen()time. - WAL mode: Enables concurrent reads during writes. Required by Honker's reader pool architecture.
For multi-process deployments, set WAL mode and ensure the busy timeout is sufficient for expected lock contention.
References
- Honker source:
/workspace/honker/ - Honker Node binding:
/workspace/honker/packages/honker-node/ - Hub identity tables (provenance):
/workspace/@alkdev/hub/docs/architecture/storage/identity.md - Operations AccessControl:
/workspace/@alkdev/operations/docs/architecture/api-surface.md - Source:
src/sqlite/