Skip to content

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 to accounts.id with CASCADE delete.
  • 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:

  1. Reads JMAPLE_DATABASE_URL.
  2. Calls jmaple.capabilities.registry.discover() to load every enabled capability, which imports their model modules and registers them on Base.metadata.
  3. Diffs Base.metadata against 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).

jmaple migrate revision -m "add Todo"
jmaple migrate upgrade head

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).