- 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
743 lines
31 KiB
Markdown
743 lines
31 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-06-01
|
|
last_updated: 2026-06-01
|
|
---
|
|
|
|
# Pivot: Honker SQLite Adapter, dbtype Fold, and Event Target Integration
|
|
|
|
Research document for the architecture pivot from `@libsql/client` to Honker,
|
|
folding `@alkdev/dbtype` Phase 0 into `src/utils/`, and adding a pubsub
|
|
`HonkerEventTarget`.
|
|
|
|
## Motivation
|
|
|
|
1. Honker provides SQLite + pub/sub + streams + queues + locks + scheduler in
|
|
a single `.db` file via a Rust NAPI binding. This replaces both
|
|
`@libsql/client` (SQLite only) and the conceptual need for Redis.
|
|
2. `@alkdev/dbtype` Phase 0 is already consumed as `@alkdev/drizzlebox`. With
|
|
SQLite as the sole target, the multi-dialect column mappings are dead
|
|
weight. Folding the SQLite-relevant subset into `src/utils/` eliminates an
|
|
external dependency and co-locates utility code with the tables it
|
|
derives schemas from.
|
|
3. `@alkdev/pubsub` defines a `TypedEventTarget` interface that Honker's
|
|
primitives (`notify`/`listen`, `stream`/`stream.subscribe`) map to
|
|
naturally. A `HonkerEventTarget` adapter lets storage's downstream
|
|
consumers use the same pubsub API regardless of whether events route
|
|
in-process, through Honker, through Redis, or over WebSocket.
|
|
4. The hub-spoke model needs events for call protocol routing, cache
|
|
invalidation, flowgraph reactive updates, and cross-process coordination.
|
|
Honker handles all of these within SQLite.
|
|
|
|
## Architecture Changes
|
|
|
|
### Current Structure
|
|
|
|
```
|
|
src/
|
|
├── graphs/ # Metagraph Module + bridge (no db deps)
|
|
│ ├── modules/ # CallGraph, SecretGraph, Metagraph
|
|
│ ├── bridge.ts # moduleToDbSchema, validateNode, validateEdge
|
|
│ ├── crypto.ts # encrypt, decrypt, generateEncryptionKey
|
|
│ └── mod.ts
|
|
├── pg/ # Placeholder (to be removed)
|
|
└── sqlite/ # SQLite host
|
|
├── client.ts # createSqliteDatabase(@libsql/client)
|
|
├── mod.ts
|
|
├── relations.ts
|
|
├── schema.ts
|
|
└── tables/ # Flat: graphTypes, nodeTypes, edgeTypes, graphs, nodes, edges, actors
|
|
```
|
|
|
|
Dependencies: `@alkdev/drizzlebox` (external npm), `@libsql/client`, `drizzle-orm`
|
|
|
|
### Target Structure
|
|
|
|
```
|
|
src/
|
|
├── graphs/ # Unchanged
|
|
│ ├── modules/
|
|
│ ├── bridge.ts
|
|
│ ├── crypto.ts
|
|
│ └── mod.ts
|
|
└── sqlite/
|
|
├── adapter.ts # NEW: Drizzle-Honker session adapter
|
|
├── event-target.ts # NEW: HonkerEventTarget implements pubsub TypedEventTarget
|
|
├── client.ts # UPDATED: createSystemDatabase(client), createTenantDatabase(client)
|
|
├── mod.ts # UPDATED: re-exports
|
|
├── relations.ts # UPDATED: remove actors relations
|
|
├── schema.ts # UPDATED: remove actors, remove pg refs
|
|
├── utils/ # NEW: folded from @alkdev/dbtype Phase 0 (SQLite only)
|
|
│ ├── schema.ts # createSelectSchema, createInsertSchema, createUpdateSchema
|
|
│ ├── column.ts # Column→TypeBox mappings (SQLite-only dispatch)
|
|
│ ├── types.ts # Public TypeScript interfaces (merged from schema.types + schema.types.internal)
|
|
│ ├── constants.ts # Integer range constants
|
|
│ └── utils.ts # isColumnType, isWithEnum, type helpers
|
|
└── tables/ # Restructured into subdirectories
|
|
├── common.ts # shared column definitions, ACTOR_TYPE removed
|
|
├── metagraph/ # graph_types, node_types, edge_types, graphs, nodes, edges
|
|
│ └── index.ts
|
|
└── identity/ # NEW: accounts, organizations, org_members, api_keys, audit_logs
|
|
└── index.ts
|
|
```
|
|
|
|
Removed: `src/pg/`, `actors` table, `@alkdev/drizzlebox` external dep,
|
|
`@libsql/client` dep.
|
|
|
|
New deps: `@alkdev/pubsub` (peer dep for `TypedEventTarget` type only),
|
|
`honker` (replaces `@libsql/client`).
|
|
|
|
## 1. Fold dbtype Phase 0 → `src/sqlite/utils/`
|
|
|
|
### What to fold
|
|
|
|
Only SQLite-relevant code from `@alkdev/dbtype/src/`:
|
|
|
|
| Source (dbtype) | Target (storage) | Changes |
|
|
|-----------------|-------------------|---------|
|
|
| `schema.ts` | `utils/schema.ts` | Remove `PgEnum` import/handling, remove `isPgEnum` calls. Remove `handleEnum()`. Keep `createSelectSchema`, `createInsertSchema`, `createUpdateSchema`. |
|
|
| `column.ts` | `utils/column.ts` | Strip all non-SQLite dispatch branches. Remove PG, MySQL, SingleStore type imports and `isColumnType` branches. Keep: `SQLiteInteger`, `SQLiteReal`, `SQLiteText` handling. Keep generic dispatch (`dataType`-based) as fallback. Keep `literalSchema`, `jsonSchema`, `bufferSchema`, `mapEnumValues`. |
|
|
| `column.types.ts` | `utils/types.ts` (merged) | Strip non-SQLite type branches. Keep `SQLiteInteger` → `t.TInteger`. Keep generic fallback types. |
|
|
| `schema.types.ts` | `utils/types.ts` (merged) | Remove `PgEnum` overloads. |
|
|
| `schema.types.internal.ts` | `utils/types.ts` (merged) | Keep `Conditions`, `BuildRefine`, `BuildSchema`, `NoUnknownKeys`. |
|
|
| `constants.ts` | `utils/constants.ts` | Keep as-is (ranges used by SQLite integer/real/bigiint mappings). |
|
|
| `utils.ts` | `utils/utils.ts` | Remove `PgEnum` type alias. Keep `isColumnType`, `isWithEnum`. Keep `JsonSchema`, `BufferSchema` types. Remove unused type helpers. |
|
|
|
|
### What NOT to fold
|
|
|
|
- PG/MySQL/SingleStore column type handlers (dead code for SQLite-only)
|
|
- `isPgEnum` and `handleEnum` (PostgreSQL-specific)
|
|
- `createSchemaFactory` (no known consumer in storage; can add later if needed)
|
|
- The `scripts/probe-e2e.ts` (dbtype Phase 1 POC, not relevant)
|
|
|
|
### Import changes in existing table files
|
|
|
|
Every table file currently imports from `@alkdev/drizzlebox`:
|
|
|
|
```ts
|
|
// Before
|
|
import { createInsertSchema, createSelectSchema } from "@alkdev/drizzlebox";
|
|
|
|
// After
|
|
import { createInsertSchema, createSelectSchema } from "../utils/schema.ts";
|
|
```
|
|
|
|
The `createSelectSchema` and `createInsertSchema` functions produce the same
|
|
TypeBox schemas — the API surface is identical, the implementation just
|
|
excludes non-SQLite branches.
|
|
|
|
### Deno.json import map changes
|
|
|
|
```jsonc
|
|
// Before
|
|
"imports": {
|
|
"@alkdev/drizzlebox": "npm:@alkdev/drizzlebox",
|
|
"@libsql/client": "npm:@libsql/client",
|
|
// ...
|
|
}
|
|
|
|
// After (remove drizzlebox and libsql, add honker and pubsub peer)
|
|
"imports": {
|
|
"honker": "npm:honker",
|
|
"@alkdev/pubsub": "npm:@alkdev/pubsub",
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Note: `@alkdev/pubsub` is a peer dependency for the `TypedEventTarget` type.
|
|
It is only needed by consumers who use the `HonkerEventTarget`. The
|
|
`src/graphs/` module remains zero-dep.
|
|
|
|
## 2. Drizzle-Honker Session Adapter (`src/sqlite/adapter.ts`)
|
|
|
|
### Problem
|
|
|
|
Drizzle ORM expects a session that implements `SQLiteSession<'sync'>`. Honker
|
|
provides `Database.query(sql, params)` for reads and `Transaction.execute(sql,
|
|
params)` for writes. These don't implement Drizzle's session interface.
|
|
|
|
### 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.stream('events') → Honker durable stream
|
|
├── db.$client.queue('jobs') → Honker work queue
|
|
└── db.$client.listen('channel') → Honker listener
|
|
```
|
|
|
|
Three adapter classes:
|
|
|
|
| Class | 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 primitives |
|
|
|
|
### Accessing Honker from Drizzle
|
|
|
|
- `db.$client` — the Honker `Database` instance (outside transactions)
|
|
- `tx.$honkerTx` — the Honker `Transaction` instance (inside Drizzle tx callbacks)
|
|
|
|
```ts
|
|
db.transaction((tx) => {
|
|
tx.insert(schema.nodes).values({ graphId, key: "call-1", attributes: {} }).run();
|
|
tx.$honkerTx.notify("nodes:created", { graphId, key: "call-1" });
|
|
});
|
|
```
|
|
|
|
Both data write and notification commit atomically.
|
|
|
|
### Known challenges (need POC validation)
|
|
|
|
1. **No `lastInsertRowid` from Honker `execute()`** — Drizzle's `run()`
|
|
needs `lastInsertRowid` for auto-increment inserts. Honker's JS API
|
|
doesn't expose it. POC 1 confirmed workaround: `tx.query("SELECT last_insert_rowid() as id")` returns the rowid.
|
|
|
|
2. **Object rows only** — Honker `query()` returns
|
|
`Array<Record<string, any>>`. Drizzle's `values()` mode expects raw
|
|
arrays. The adapter must convert via `Object.values()` with column
|
|
order from the query metadata.
|
|
|
|
3. **Drizzle internal API dependency** — The adapter imports from
|
|
`drizzle-orm/sqlite-core/session`, which is not a guaranteed-stable API.
|
|
POC 1 confirmed all needed classes are accessible and extendable.
|
|
|
|
4. **Session constructor shape** — POC 1 confirmed: `SQLiteSession` takes
|
|
`(mode, executeMethod, query, cache?, queryMetadata?)`. `BaseSQLiteDatabase`
|
|
is accessible from `drizzle-orm/sqlite-core/db`. The `LibSQLSession` in
|
|
`drizzle-orm/libsql/session` is a working reference implementation.
|
|
|
|
5. **`:memory:` databases don't work** — Honker's reader pool creates
|
|
separate in-memory databases. File-based paths must be used. POC 1
|
|
confirmed this. Not a real limitation — production uses file paths,
|
|
tests use temp files.
|
|
|
|
## 3. HonkerEventTarget (`src/sqlite/event-target.ts`)
|
|
|
|
### Interface
|
|
|
|
Implements `@alkdev/pubsub`'s `TypedEventTarget<TEvent>`:
|
|
|
|
```ts
|
|
interface HonkerEventTargetOptions {
|
|
db: Database;
|
|
mode: "ephemeral" | "durable";
|
|
streamName?: string;
|
|
prefix?: string;
|
|
}
|
|
|
|
function createHonkerEventTarget<TEvent>(options: HonkerEventTargetOptions): HonkerEventTarget<TEvent>
|
|
```
|
|
|
|
### Ephemeral mode mapping
|
|
|
|
Maps to Honker's `notify()` / `listen()`:
|
|
|
|
```ts
|
|
addEventListener(type, callback):
|
|
if (!listeners.has(type)):
|
|
honkerListener = db.listen(type)
|
|
// start async loop: for await (notification of honkerListener)
|
|
// dispatch CustomEvent with notification as detail
|
|
listeners.set(type, callback)
|
|
|
|
dispatchEvent(event):
|
|
db.notify(event.type, event.detail) // or tx.notify() inside transaction
|
|
|
|
removeEventListener(type, callback):
|
|
listeners.delete(type, callback)
|
|
if (no more listeners for type):
|
|
honkerListener.close()
|
|
```
|
|
|
|
### Durable mode mapping
|
|
|
|
Maps to Honker's `Stream.publish()` / `Stream.subscribe()`:
|
|
|
|
```ts
|
|
addEventListener(type, callback):
|
|
if (!subscriptions.has(type)):
|
|
stream = db.stream(streamName ?? type)
|
|
subscription = stream.subscribe(consumerName)
|
|
// start async loop: for await (event of subscription)
|
|
// dispatch CustomEvent with event.payload as detail
|
|
subscriptions.set(type, { stream, subscription, callback })
|
|
|
|
dispatchEvent(event):
|
|
db.stream(streamName).publish(event.detail)
|
|
// or stream.publishTx(tx) inside transaction
|
|
|
|
removeEventListener(type, callback):
|
|
subscriptions.delete(type, callback)
|
|
if (no more subscriptions for type):
|
|
subscription.close()
|
|
```
|
|
|
|
### Topic routing considerations
|
|
|
|
pubsub uses `type:id` composite topics (e.g., `"call.responded:req-123"`).
|
|
Honker channels are flat strings. The mapping is 1:1 — no transformation
|
|
needed. But:
|
|
|
|
- **Ephemeral**: Each unique `type:id` gets its own `db.listen()` call. For
|
|
high-cardinality topics (many request IDs), this could mean many concurrent
|
|
listeners. POC 3 confirmed multiple concurrent listeners work.
|
|
|
|
- **Durable**: Streams are name-keyed, not topic-keyed. A single stream
|
|
(e.g., `"call-protocol"`) carries all call events. The adapter would need
|
|
to handle topic filtering client-side (dispatch only to matching listeners)
|
|
rather than using separate streams per topic.
|
|
|
|
- **Suggested split**: Call protocol events → durable stream. Cache
|
|
invalidation, UI push → ephemeral channels. The configuration for which
|
|
topics go where is a consumer decision, not built into the adapter.
|
|
|
|
- **Latency consideration**: POC 2 measured ~17ms median for
|
|
`notify`→`listen` round-trip. For hot-path call protocol request/response
|
|
in a single process, pair the Honker event target with an in-process
|
|
`EventTarget` (pubsub's default) for sub-ms latency. The Honker event
|
|
target provides durability and cross-process coordination; the default
|
|
EventTarget provides speed. A composite pattern (dispatch to both) could
|
|
serve both needs.
|
|
|
|
### Consumer name strategy
|
|
|
|
For durable mode, each consumer needs a unique name for offset tracking.
|
|
Suggested pattern: `{processId}:{topic}` or a configurable consumer group ID.
|
|
|
|
## 4. Client Factory Update (`src/sqlite/client.ts`)
|
|
|
|
### Before
|
|
|
|
```ts
|
|
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
|
import type { Client } from "@libsql/client";
|
|
|
|
export function createSqliteDatabase(client: Client): LibSQLDatabase<SqliteSchema> {
|
|
return drizzle(client, { schema });
|
|
}
|
|
```
|
|
|
|
### After
|
|
|
|
The factory accepts a pre-created Honker `Database` instance (injectable
|
|
client pattern, per ADR-004). It constructs the Drizzle-Honker adapter
|
|
internally.
|
|
|
|
```ts
|
|
import type { Database as HonkerDatabase } from "honker";
|
|
|
|
export type SqliteDatabase = BaseSQLiteDatabase<'sync', SqliteSchema>;
|
|
|
|
export function createSystemDatabase(client: HonkerDatabase): SqliteDatabase {
|
|
return createDrizzleWithAdapter(client, { schema });
|
|
}
|
|
|
|
export function createTenantDatabase(client: HonkerDatabase): SqliteDatabase {
|
|
return createDrizzleWithAdapter(client, { schema });
|
|
}
|
|
```
|
|
|
|
`createDrizzleWithAdapter` is the internal factory that constructs the
|
|
`HonkerSQLiteSession` and wraps it in a `BaseSQLiteDatabase`. This may
|
|
require using `drizzle-orm/sqlite-core` internals rather than the
|
|
`drizzle-orm/libsql` high-level wrapper.
|
|
|
|
## 5. Table Restructure
|
|
|
|
### Remove actors table
|
|
|
|
Per existing ADRs (035, 041), actors are being replaced by ACL nodes in
|
|
graph instances. Remove `src/sqlite/tables/actors.ts` and all exports.
|
|
|
|
### Reorganize into subdirectories
|
|
|
|
```
|
|
src/sqlite/tables/
|
|
├── common.ts # commonCols (UPDATED: remove ACTOR_TYPE)
|
|
├── metagraph/
|
|
│ ├── index.ts # barrel
|
|
│ ├── graph-types.ts # renamed from graphTypes.ts
|
|
│ ├── node-types.ts # renamed from nodeTypes.ts
|
|
│ ├── edge-types.ts # renamed from edgeTypes.ts
|
|
│ ├── graphs.ts
|
|
│ ├── nodes.ts
|
|
│ └── edges.ts
|
|
└── identity/
|
|
├── index.ts # barrel
|
|
├── accounts.ts
|
|
├── organizations.ts
|
|
├── org-members.ts
|
|
├── api-keys.ts
|
|
└── audit-logs.ts
|
|
```
|
|
|
|
### Import updates in table files
|
|
|
|
All table files currently importing `@alkdev/drizzlebox` update to
|
|
`../utils/schema.ts` (or `../../utils/schema.ts` depending on depth).
|
|
|
|
The `commonCols` import path changes from `./common.ts` to `../common.ts`
|
|
for metagraph/identity subdirectories.
|
|
|
|
## 6. Domain-Specific Tables (Future, documented for scope)
|
|
|
|
The current metagraph tables store node/edge attributes as JSON. For known
|
|
graph types (CallGraph, SecretGraph, AclGraph), native columns give better
|
|
type safety and query performance.
|
|
|
|
This is **not part of the initial pivot**. But the pivot should not prevent
|
|
it. The folded `utils/` code already provides `createInsertSchema` /
|
|
`createSelectSchema` which derives TypeBox schemas from Drizzle tables with
|
|
native columns. When domain tables are added later, they follow the same
|
|
pattern as the metagraph tables.
|
|
|
|
### Where OperationSpecs fit
|
|
|
|
Domain tables would come with auto-generated `OperationSpec[]` — flat arrays
|
|
describing CRUD operations for each table. The hub/spoke imports these specs,
|
|
registers them in the operations registry, and the call protocol handles
|
|
execution.
|
|
|
|
This means storage's "repository layer" is not hand-written query functions.
|
|
It's **table definitions + operation contract definitions**. The consumer
|
|
(hub/spoke) provides the execution layer.
|
|
|
|
```ts
|
|
// In storage: table definition + operation contracts
|
|
export const callNodes = sqliteTable("call_nodes", { ... });
|
|
export const callNodeOperations: OperationSpec[] = [
|
|
{ name: "create", namespace: "call_nodes", type: "mutation", ... },
|
|
{ name: "find", namespace: "call_nodes", type: "query", ... },
|
|
// ...
|
|
];
|
|
|
|
// In hub: register operations
|
|
for (const spec of callNodeOperations) {
|
|
registry.registerSpec(spec);
|
|
registry.registerHandler(specId, (input) => db.insert(callNodes).values(input).returning());
|
|
}
|
|
```
|
|
|
|
The OperationSpec type is straightforward (`@alkdev/operations` peer dep for
|
|
type only). Storage defines the contract; the hub provides the handler.
|
|
|
|
## 7. Identity Tables (New, documented for scope)
|
|
|
|
Per `sqlite-host.md` and ADR-040/041, these live in the system database:
|
|
|
|
| Table | Key Columns | Purpose |
|
|
|-------|-------------|---------|
|
|
| `accounts` | id, email, name, accessLevel | User accounts |
|
|
| `organizations` | id, slug, name, ownerId | Tenant organizations |
|
|
| `org_members` | id, orgId, accountId, level | Membership junction |
|
|
| `api_keys` | id, keyHash, ownerId, name | API key auth |
|
|
| `audit_logs` | id, actorType, actorId, action, resource | Append-only audit |
|
|
|
|
These need actual implementation (currently only spec in architecture docs).
|
|
They're part of the pivot scope but can be implemented after the adapter and
|
|
utils fold.
|
|
|
|
## 8. Event Pipeline: Operations → Flowgraph → Storage
|
|
|
|
### How the pieces connect after the pivot
|
|
|
|
```
|
|
[operations: OperationRegistry]
|
|
│
|
|
│ call.requested / call.responded / call.completed / call.aborted / call.error
|
|
▼
|
|
[pubsub: createPubSub({ eventTarget: honkerEventTarget })]
|
|
│
|
|
├──→ [flowgraph: WorkflowReactiveRoot.append(event)]
|
|
│ └── signal propagation → UI updates
|
|
│
|
|
├──→ [storage: db.stream("call-protocol").publish(event)]
|
|
│ └── durable replay for crash recovery
|
|
│
|
|
└──→ [spoke sync: via WebSocket or Honker fan-out]
|
|
└── cross-process coordination
|
|
```
|
|
|
|
The `HonkerEventTarget` is the single integration point that makes this work.
|
|
Operations doesn't need to know about Honker. Flowgraph doesn't need to know
|
|
about Honker. They both use `@alkdev/pubsub`'s `TypedEventTarget`. The
|
|
`HonkerEventTarget` is just another adapter in pubsub's existing portfolio
|
|
(alongside Redis, WebSocket, Worker).
|
|
|
|
### Hub-spoke event routing
|
|
|
|
```
|
|
Hub (system.db + tenant-{orgId}.db)
|
|
├── HonkerEventTarget (durable: call-protocol stream)
|
|
├── WebSocketServerEventTarget (spoke fan-out)
|
|
└── In-process EventTarget (local subscribers)
|
|
|
|
Spoke (tenant-{orgId}.db)
|
|
├── HonkerEventTarget (ephemeral: local channels)
|
|
├── WebSocketClientEventTarget (hub connection)
|
|
└── In-process EventTarget (local subscribers)
|
|
```
|
|
|
|
Both hub and spoke use the same code. Same `createPubSub({ eventTarget })`.
|
|
Different event target instance determines the routing.
|
|
|
|
## POC Plan
|
|
|
|
Before committing to the full pivot, validate the critical path with targeted
|
|
POCs. Each POC answers one specific hard question.
|
|
|
|
### POC 1: Drizzle queries through Honker
|
|
|
|
**Question**: Can we construct a `BaseSQLiteDatabase` that routes queries
|
|
through Honker's `Database.query()` and `Transaction.execute()`?
|
|
|
|
**Approach**:
|
|
1. Import `SQLiteSession`, `SQLitePreparedQuery`, `SQLiteTransaction` from
|
|
`drizzle-orm/sqlite-core/session`.
|
|
2. Implement `HonkerSQLiteSession` that delegates to `Database.query()`.
|
|
3. Implement `HonkerPreparedQuery` with `run()`, `all()`, `get()`.
|
|
4. Wire it into a `BaseSQLiteDatabase` instance.
|
|
5. Run a simple `db.select().from(graphs)` against a Honker-opened `.db` file.
|
|
|
|
**Hard fail conditions**:
|
|
- Drizzle's `SQLiteSession` requires constructor arguments we can't provide
|
|
from Honker's primitives.
|
|
- The `BaseSQLiteDatabase` constructor requires internals not exported from
|
|
`drizzle-orm/sqlite-core`.
|
|
- `lastInsertRowid` is required and no workaround exists.
|
|
|
|
### POC 2: Honker pub/sub from same process
|
|
|
|
**Question**: Does `db.notify()` → `db.listen()` work within the same Node.js
|
|
process? What's the latency?
|
|
|
|
**Approach**:
|
|
1. Open a Honker database.
|
|
2. Start `db.listen("test-channel")`.
|
|
3. Call `db.notify("test-channel", { hello: "world" })`.
|
|
4. Verify the listener receives the notification.
|
|
5. Measure round-trip latency (10,000 iterations).
|
|
|
|
**Hard fail conditions**:
|
|
- Same-process `notify`/`listen` doesn't work (would require two DB instances
|
|
or fallback to in-process EventTarget).
|
|
- Latency exceeds 10ms median (would be too slow for call protocol
|
|
request/response).
|
|
|
|
### POC 3: Concurrent listeners and stream subscriptions
|
|
|
|
**Question**: Can multiple `Listener` and `StreamSubscription` instances
|
|
coexist on the same `Database`? Do they interleave correctly?
|
|
|
|
**Approach**:
|
|
1. Open a Honker database.
|
|
2. Start two `db.listen()` calls on different channels.
|
|
3. Start a `db.stream("test-stream").subscribe("consumer-1")`.
|
|
4. Publish to both channels and the stream concurrently.
|
|
5. Verify all three consumers receive their events without blocking each
|
|
other.
|
|
|
|
**Hard fail conditions**:
|
|
- Only one `Listener` can be active at a time (would require serialization).
|
|
- `Listener.next()` blocks the JS event loop (would prevent concurrent
|
|
subscriptions).
|
|
|
|
### POC 4: Transactional notify + data write
|
|
|
|
**Question**: Does `tx.notify()` + `tx.execute()` in the same transaction
|
|
atomically commit both the data write and the notification?
|
|
|
|
**Approach**:
|
|
1. Open a Honker database.
|
|
2. Start `db.listen("test-channel")`.
|
|
3. Begin a transaction: `const tx = db.transaction()`.
|
|
4. Execute a data write: `tx.execute("INSERT INTO test ...")`.
|
|
5. Call `tx.notify("test-channel", { inserted: true })`.
|
|
6. Rollback the transaction.
|
|
7. Verify the listener does NOT receive the notification.
|
|
8. Repeat with commit instead of rollback.
|
|
9. Verify the listener DOES receive the notification after commit.
|
|
|
|
**Hard fail conditions**:
|
|
- Notifications fire immediately on `tx.notify()` regardless of commit/rollback
|
|
(breaks transactional outbox semantics).
|
|
|
|
### POC file structure
|
|
|
|
```
|
|
poc/
|
|
├── README.md # POC objectives, how to run
|
|
├── 01-drizzle-honker-adapter.ts # POC 1
|
|
├── 02-same-process-pubsub.ts # POC 2
|
|
├── 03-concurrent-listeners.ts # POC 3
|
|
├── 04-transactional-notify.ts # POC 4
|
|
└── shared/
|
|
└── honker-helpers.ts # Shared setup/teardown utilities
|
|
```
|
|
|
|
Each POC is a standalone script that can be run with `deno run` or `node`.
|
|
The POCs use the actual `honker` npm package from the workspace.
|
|
|
|
## ADR Updates Required
|
|
|
|
| ADR | Action | Reason |
|
|
|-----|--------|--------|
|
|
| ADR-018 (dbtype integration post-v1) | **Revise** | No longer deferred — Phase 0 folds in now as `src/sqlite/utils/` |
|
|
| ADR-038 (SQLite-first, PG removed) | **Confirm** | Pivot confirms this. Remove `src/pg/` and PG imports from deno.json. |
|
|
| ADR-039 (Honker as SQLite extension) | **Confirm** | Pivot is the implementation of this decision. |
|
|
| ADR-044 (Drizzle-Honker adapter) | **Update** | Add specifics about session/PreparedQuery/Transaction classes. |
|
|
| ADR-005 (drizzle + typebox via drizzlebox) | **Revise** | Drizzlebox is no longer an external dep. It's `src/sqlite/utils/`. |
|
|
| ADR-019 (JSON text for schema columns) | **Confirm** | Still relevant for metagraph attributes. Domain tables add native columns alongside JSON. |
|
|
| ADR-033 (JSON path queries for v1) | **Revise** | Repository layer is now "operation specs," not hand-written CRUD. The query approach (JSON path vs native) still applies, but the output format changes. |
|
|
| New ADR | Fold dbtype Phase 0 as utils | Records the decision to fold SQLite mappings into `src/sqlite/utils/`. |
|
|
| New ADR | HonkerEventTarget adapter | Records the decision to implement pubsub TypedEventTarget on Honker primitives. |
|
|
| New ADR | Operation specs as repository surface | Records that storage outputs OperationSpec[], not hand-written query functions. |
|
|
|
|
## Architecture Doc Updates Required
|
|
|
|
| Document | Changes |
|
|
|----------|---------|
|
|
| `overview.md` | Subpath exports (`./pg` removed), dependency list, module structure, "what's not done" section, system/tenant DB model |
|
|
| `forward-look.md` | dbtype section (folded, not deferred), repository layer strategy (operations, not CRUD), pointer abstraction (still deferred) |
|
|
| `honker-integration.md` | Add HonkerEventTarget section, update adapter section with verified API, add event pipeline diagram, resolve OQ-26 (Honker replaces Redis for single-node) |
|
|
| `sqlite-host.md` | Table structure changes (subdirectories, actors removed, identity tables), client factory signature, Honker import instead of libsql |
|
|
| `metagraph-module.md` | No changes (Module format is independent of storage host) |
|
|
| `schema-evolution.md` | Minor: `createSelectSchema`/`createInsertSchema` now come from local utils, not external dep |
|
|
| `open-questions.md` | Resolve OQ-17 (operations, not CRUD), OQ-18 (auto-generated specs from tables), OQ-26 (Honker replaces Redis). Update OQ-19 (bridge package location → it's storage itself). |
|
|
| `encrypted-data.md` | No changes |
|
|
|
|
## Implementation Order (post-POC)
|
|
|
|
If POCs pass, the implementation sequence should be:
|
|
|
|
1. **Fold dbtype → `src/sqlite/utils/`** — import path changes only, no
|
|
behavioral changes. Run existing tests to verify.
|
|
2. **Remove `src/pg/` and actors table** — clean up dead code. Update deno.json
|
|
imports, remove `postgres` and PG-related entries.
|
|
3. **Restructure tables into subdirectories** — move files, update relative
|
|
imports, update `schema.ts` and `relations.ts`.
|
|
4. **Implement `adapter.ts`** — Drizzle-Honker session adapter based on POC
|
|
1 findings.
|
|
5. **Update `client.ts`** — `createSystemDatabase()` / `createTenantDatabase()`
|
|
taking Honker `Database` instances.
|
|
6. **Implement `event-target.ts`** — `HonkerEventTarget` based on POC 2-4
|
|
findings.
|
|
7. **Implement identity tables** — `src/sqlite/tables/identity/`.
|
|
8. **Update architecture docs** — bring all documents in line with the new
|
|
structure.
|
|
9. **Add domain tables** (future, not in initial pivot) — native-column
|
|
tables for CallGraph, SecretGraph, etc. with OperationSpec[] output.
|
|
|
|
Steps 1-3 are mechanical (no new behavior). Steps 4-5 depend on POC 1.
|
|
Step 6 depends on POCs 2-4. Step 7 can proceed independently. Steps 8-9
|
|
are follow-ups.
|
|
|
|
## POC Results (2026-06-01)
|
|
|
|
### POC 1: Drizzle queries through Honker — PASS
|
|
|
|
- `SQLiteSession`, `SQLitePreparedQuery`, `SQLiteTransaction` are all
|
|
accessible from `drizzle-orm/sqlite-core/session` — can be extended.
|
|
- `BaseSQLiteDatabase` is accessible from `drizzle-orm/sqlite-core/db`.
|
|
- Honker `query()` returns `{ columnName: value }` object rows, compatible
|
|
with Drizzle's `mapResultRow()`.
|
|
- `last_insert_rowid()` accessible via `tx.query("SELECT last_insert_rowid() as id")`.
|
|
- `tx.execute()` returns a number (affected rows count for UPDATE/DELETE).
|
|
- JSON-mode columns need manual `JSON.parse()` in the adapter.
|
|
- `:memory:` databases DON'T work — reader pool gets separate in-memory DBs.
|
|
Always use file-based paths (or `:memory:` with a single reader, which
|
|
defeats Honker's concurrent reader design).
|
|
- `LibSQLSession` in `drizzle-orm/libsql/session` is a good reference
|
|
implementation for the adapter pattern.
|
|
|
|
Session method signatures discovered:
|
|
```
|
|
SQLiteSession methods:
|
|
prepareOneTimeQuery — key method to implement
|
|
all, run, get, values — delegated to PreparedQuery
|
|
extractRaw*ValueFromBatchResult — batch result extraction
|
|
count — cursor count
|
|
|
|
SQLitePreparedQuery methods:
|
|
execute, getQuery, mapAllResult, mapGetResult, mapResult, mapRunResult
|
|
queryWithCache
|
|
|
|
SQLiteTransaction methods:
|
|
rollback — only explicit method (commit handled by Session.transaction)
|
|
```
|
|
|
|
### POC 2: Same-process pub/sub — PASS (with latency caveat)
|
|
|
|
- `db.notify()` → `db.listen()` works within the same process.
|
|
- Multiple concurrent listeners on different channels work.
|
|
- Cross-channel isolation works — listeners only receive notifications on
|
|
their subscribed channel.
|
|
- **Latency**: ~17ms median, ~29ms P99 (100 samples). This is higher than
|
|
ideal for call protocol request/response. The `Listener` uses a polling
|
|
fallback (`updateEvents().next()`) internally. For hot-path events where
|
|
sub-ms latency matters, the in-process `EventTarget` should be preferred.
|
|
Honker listeners are better suited for cross-process and durable scenarios.
|
|
|
|
### POC 3: Concurrent listeners and streams — PASS
|
|
|
|
- Multiple `Listener` instances coexist on the same `Database`.
|
|
- `Stream.publish()` / `Stream.subscribe()` works.
|
|
- `readSince(offset, limit)` returns historical events.
|
|
- `StreamSubscription` implements `AsyncIterableIterator` — works with
|
|
`for await...of`.
|
|
- Consumer offsets are tracked and persisted.
|
|
- Concurrent stream + listener operation works.
|
|
- `publishTx(tx, payload)` works within a transaction.
|
|
- **Caveat**: `db.close()` doesn't cleanly exit when listeners are still
|
|
polling. Need to close all listeners before closing the DB.
|
|
|
|
### POC 4: Transactional notify + data write — PASS (atomicity confirmed)
|
|
|
|
- `tx.notify()` + `tx.execute()` — notification fires ONLY after `tx.commit()`.
|
|
Before commit, no notification is received. **Transactional semantics hold.**
|
|
- On `tx.rollback()` — notification is NOT sent. Data is NOT persisted.
|
|
**Atomicity holds for both data and events.**
|
|
- `db.notifyTx(tx, channel, payload)` — convenience wrapper, same semantics.
|
|
- `queue.enqueueTx(tx, payload)` — job is only visible after commit. On
|
|
rollback, job does not appear in the queue. **Transactional outbox works.**
|
|
- Read-your-writes: after receiving a notification, the data IS queryable
|
|
(no stale read issue with single DB).
|
|
|
|
**This is the most important result.** Honker provides true transactional
|
|
outbox semantics — data writes and event/queue publishes commit atomically.
|
|
This eliminates the dual-write problem that necessitated Redis for PostgreSQL.
|
|
|
|
## Open Research Questions
|
|
|
|
1. ~~**Drizzle session construction**~~ — RESOLVED by POC 1. All needed
|
|
classes are accessible and extendable. `LibSQLSession` is the reference.
|
|
|
|
2. ~~**Honker listener concurrency model**~~ — RESOLVED by POC 3. Multiple
|
|
listeners coexist. The `updateEvents` mechanism works for concurrent
|
|
polling. `db.close()` requires all listeners to be closed first.
|
|
|
|
3. **Consumer name management for streams** — When a hub restarts, durable
|
|
stream consumers resume from their last saved offset. But the consumer
|
|
name must be stable across restarts. Need a naming convention that's
|
|
deterministic (e.g., `{service}:{pid}` is not restart-stable).
|
|
|
|
4. **Schema migrations** — Honker opens a single `.db` file. Drizzle Kit
|
|
supports SQLite migrations but expects `better-sqlite3` or `libsql`. Need
|
|
to verify that `drizzle-kit push` or `drizzle-kit generate` works with the
|
|
Honker adapter, or whether we need a custom migration runner.
|
|
|
|
5. **OperationSpec generation from tables** — What's the concrete mapping?
|
|
For each table: one `create` mutation, one `find` query, one `list` query,
|
|
one `update` mutation, one `delete` mutation? Or something more
|
|
domain-specific? This is a design question, not a POC question.
|
|
|
|
6. **In-process + Honker composite event target** — POC 2 showed ~17ms
|
|
latency for Honker notify→listen. For single-process hot paths, an
|
|
in-process EventTarget is sub-ms. A composite that dispatches to both
|
|
(in-process for speed, Honker for durability/cross-process) would be the
|
|
ideal default for a hub. Needs design work. |