By default,Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
sqliteStore / d1Store write agent sessions and messages to their own
tables invisible to @vertz/server. Good for single-tenant apps, wrong for anything
with users and tenants — Session.list({ where }) can’t be called from app code
and rules.* (authenticated, tenant-scoped, row-level where) never fires on
agent reads.
The entity bridge fixes this: one factory call turns the store’s tables into
first-class Vertz entities registered with createServer, so app-side reads flow
through the CRUD pipeline with full RLS.
- Same tables, same writes —
sqliteStore/d1Storeare unchanged on the hot path. - Reads from app code (
/api/agent-sessionroutes,ctx.entities['agent-session'].list()) enforce access rules. - Tenant auto-detection picks up
tenantIdon the session row; extended schemas with a.tenant()relation inherit the chain.
Setup
Three pieces: define tables with the exported column packs, call the factory, register the entities.Default access rules
defineAgentEntities() applies user-scoped row-level rules on every op so the
happy path is safe. ctx.userId / ctx.tenantId are injected by a
before.create hook — callers never need to pass them explicitly, and if they
do, ctx.userId wins (prevents impersonation).
| Op | Session | Message |
|---|---|---|
list | rules.where({ userId: rules.user.id }) | rules.where({ userId: rules.user.id }) |
get | rules.where({ userId: rules.user.id }) | rules.where({ userId: rules.user.id }) |
create | rules.authenticated() (+ hook) | denied (store writes) |
update | rules.where({ userId: rules.user.id }) | denied |
delete | rules.where({ userId: rules.user.id }) | denied |
Reading agent data
Flow from the client goes through the auto-generated route:EntityContext:
db.agentSessions.list(...) from handler code and expect RLS —
that path is the raw DatabaseClient delegate and bypasses the entity
pipeline. It’s fine for internal/trusted code (migrations, admin tasks) but
user-facing queries must go through the routes or ctx.entities.*.
Extending the schema
Add your own columns and relations by spreading the column packs and building your ownd.table() — no special helper:
Tenant scoping
defineAgentEntities opts each entity into tenant scoping automatically when
the tenantId column (from the pack) or a ref.one() to a .tenant() table
is present. Priority: relation wins over column. If you add a .tenant()
relation on your extended session, drop tenantId from the spread to avoid
a dead column:
Typed state / toolCalls (opt-in JSONB)
The packs default to d.text() for state and toolCalls — a JSON string,
byte-compatible with the legacy sqliteStore / d1Store DDL. Pass
{ useJsonb: true } to opt each pack into d.jsonb<T>(): typed reads on the
entity row, plus native JSONB emission on Postgres (indexable, typed
operators). On SQLite the on-disk shape is still TEXT; the driver auto-parses
jsonb columns on read.
AgentStore path (sqliteStore / d1Store) is orthogonal — it keeps
its own JSON.stringify / JSON.parse wrapping and writes raw SQL. On SQLite
the column emission is still TEXT, so stores and entities share the same
rows without coordination.
Migrating an existing database
Fresh installs get the correct column type from the opt-in. Existing databases need a one-off migration only if you want the on-disk type to change (Postgres). On SQLite the on-disk type isTEXT in both modes, so
no DDL change is required.
SQLite / D1: no migration needed. d.jsonb<T>() emits the same TEXT
storage — the only change is type-side (the driver parses jsonb columns
on read, so entity callers see parsed objects; the AgentStore contract
stays AgentSession.state: string). If you want to audit stored rows for
malformed JSON before opting in, run
SELECT id FROM agent_sessions WHERE json_valid(state) = 0.
Postgres: the stores (sqliteStore / d1Store) are SQLite-only —
pure-entity usage is the supported path on Postgres. Before running the
ALTER, audit for rows that can’t cast to JSONB:
ALTER ... TYPE JSONB USING ::JSONB scans every row and aborts on the
first value that isn’t valid JSON. If the table has a non-JSON legacy
value (e.g. an opaque token stored via a pre-bridge writer), the ALTER
rolls back and the schema stays TEXT.
Upgrade migration
Existing databases from earlier@vertz/agents versions must add two columns
to agent_messages. A migration SQL file ships with the package at
packages/agents/migrations/001-add-rls-columns.sql:
What the bridge does not cover
- Entity hooks on agent-loop writes.
before.create/after.createon theMessageentity do not fire for messages inserted byrun()— writes go through theAgentStore, not the entity pipeline. Reads and app-side writes via entity handlers do fire hooks normally. Tracked as #2957 (registration-time rejection of hooks on factory-produced entities). - RLS on in-loop reads.
store.loadSession()insiderun()bypasses entity rules — identity is checked byrun()directly againstsession.userId/session.tenantId. Same ownership story as before the bridge; app-side reads get the full RLS.
See also
- Design doc:
plans/agent-store-entity-bridge.md - Issue: #2847