Files
ServerKit/backend/app/models/plugin_store.py
T
Juan DenisandClaude Opus 5 caa511064d feat(plugins): give extensions a place to keep their own state
An extension that needed to remember anything had to add a model to core AND an
Alembic migration for it — which is why extension tables like
ext_serverkit_minecraft_* sit in the panel's own migration history. That is a
steep tax on a small extension and it puts plugin schema in the panel's upgrade
path. A plugin now writes to a key/value store instead:

    mine = store.for_plugin('serverkit-minecraft')
    mine.set('world:1:seed', 8675309)

Values are JSON, so dicts and lists round-trip without hand-serialising, and
rows are namespaced by slug so two plugins cannot collide on a key. Reach for a
real table when you need to query BY the data — this is a key/value store, not
a database, and the docstring says so.

Because the table is shared by every installed plugin, three things are refused
rather than trusted: a value that isn't JSON-serializable, a value over 256 KB,
and an empty or overlong key. Validation happens at set() rather than at commit
— by commit time the error is opaque and the session is already poisoned, so
the next unrelated write fails too.

has() exists because get() cannot distinguish a stored None from a missing key,
and a plugin storing None deliberately would otherwise have no way to tell.
Concurrent writers race on insert; the unique constraint makes that safe and
the loser applies its write to the winner's row instead of failing the caller.

Store rows follow the same rule as extension-declared tables: an ordinary
uninstall leaves them, so a reinstall finds its state where it left it, and
"uninstall and delete data" removes them. The purge runs after the uninstall
lifecycle hook, since a hook may legitimately read its own store on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:51:11 -04:00

55 lines
2.1 KiB
Python

"""Per-plugin key/value storage.
An extension that needed to remember anything previously had to add a model to
core plus an Alembic migration — which is why extension tables like
``ext_serverkit_minecraft_*`` live in the core migration history. That is a
steep tax on small extensions and it puts plugin schema in the panel's own
upgrade path.
One row here is one key for one plugin. ``value`` is a native JSON column (the
dominant pattern in this codebase, and dialect-agnostic across SQLite and
PostgreSQL), so a plugin stores dicts, lists and scalars without serialising by
hand. Keys are opaque to the panel; namespace inside your own key space
(``world:1:seed``) if you want structure.
This does NOT replace ``InstalledPlugin.config``: config is panel-owned settings
an admin edits from the Marketplace, whereas the store is the plugin's own
state, written at runtime by the plugin itself.
"""
from datetime import datetime
from app import db
class PluginStore(db.Model):
"""One key/value pair belonging to one installed plugin."""
__tablename__ = 'plugin_store'
id = db.Column(db.Integer, primary_key=True)
# Not a ForeignKey to installed_plugins: rows must survive the plugin row
# being deleted and re-created by a reinstall, which is the whole point of
# keeping data across an uninstall that wasn't asked to purge.
plugin_slug = db.Column(db.String(128), nullable=False, index=True)
key = db.Column(db.String(255), nullable=False)
value = db.Column(db.JSON)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('plugin_slug', 'key', name='uq_plugin_store_slug_key'),
)
def to_dict(self):
return {
'plugin_slug': self.plugin_slug,
'key': self.key,
'value': self.value,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<PluginStore {self.plugin_slug}:{self.key}>'