Persistence¶
Jmaple uses async SQLAlchemy 2.0 with Alembic for migrations. The framework manages its own tables; plugin authors add tables for their data types.
The Base and shared mixins¶
Every model in the project — framework or plugin — extends
Base from jmaple.db. Base carries a shared naming
convention so Alembic's autogenerate produces stable constraint names across
both the framework and any plugin.
JMAPObject is the mixin every object table mixes in.
It adds:
id: Mapped[str]— ULID primary key.account_id: Mapped[str]— FK toaccounts.idwithCASCADEdelete.created: Mapped[datetime]— UTC, set on insert.updated: Mapped[datetime]— UTC, set on insert; CRUD updates bump it.
from jmaple.db import Base, JMAPObject
class Todo(Base, JMAPObject):
__tablename__ = "todos"
text: Mapped[str] = mapped_column(String(512))
That's it. You do not write a per-capability changes table — see below.
The shared data_changes table¶
The framework owns one polymorphic table that backs <Type>/changes for
every capability:
data_changes
seq BIGINT PRIMARY KEY AUTOINCREMENT
account_id FK accounts.id
data_type VARCHAR(128) -- "Todo", "Note", "Bookmark", …
object_id VARCHAR(64)
change_type VARCHAR(16) -- "created" | "updated" | "destroyed"
state_counter BIGINT
at UTCDateTime
INDEX (account_id, data_type, state_counter)
CrudCapability writes one row per create / update / destroy. Your
<Type>/changes queries WHERE data_type = '<Type>' so different
capabilities never cross-contaminate.
This replaces an earlier pattern where each plugin wrote its own XChange
table. Plugins upgrading should run jmaple migrate consolidate-changes
<DataType> <table> once to copy their rows into data_changes, then drop
the legacy table.
State counters¶
state_counters holds one row per (account_id, data_type) with a monotonic
counter column. The string form of counter is the JMAP "state" string.
Bumped by MethodContext.bump_state. Use it
manually from a custom MethodHandler; CrudCapability does it for you on
every successful set / copy mutation.
Migrations¶
Alembic runs through jmaple's alembic/env.py, which:
- Reads
JMAPLE_DATABASE_URL. - Calls
jmaple.capabilities.registry.discover()to load every enabled capability, which imports their model modules and registers them onBase.metadata. - Diffs
Base.metadataagainst the live DB schema for autogenerate.
So plugin tables show up in autogenerate as long as your capability is in
JMAPLE_ENABLED_CAPABILITIES and its model module is imported by the
capability module's from . import models (or equivalent).
Review the generated script before committing — Alembic autogen is good but not infallible.
Engine and session¶
jmaple.db.engine.get_engine() returns the cached AsyncEngine; pool sizing
is read from JMAPLE_DATABASE__POOL_SIZE etc.
jmaple.db.engine.get_sessionmaker() returns the cached
async_sessionmaker.
The JMAP dispatcher creates one session per request and passes it to every
method handler in the batch via MethodContext.db. Method handlers do not
commit themselves — the dispatcher commits once at the end of the request
(or rolls back on unhandled exceptions).