mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 16:17:25 +09:00
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>
49 lines
1.6 KiB
Python
49 lines
1.6 KiB
Python
"""Per-plugin key/value store.
|
|
|
|
Gives an extension somewhere to keep its own state without adding a model and a
|
|
migration to core for every small thing it needs to remember. One row is one key
|
|
for one plugin; the value is a native JSON column, which SQLAlchemy renders as
|
|
JSON on PostgreSQL and TEXT on SQLite.
|
|
|
|
Idempotent: guards on table presence like the other migrations here.
|
|
|
|
Revision ID: 079_plugin_store
|
|
Revises: 078_themes
|
|
Create Date: 2026-07-25
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = '079_plugin_store'
|
|
down_revision = '078_themes'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade():
|
|
conn = op.get_bind()
|
|
inspector = sa.inspect(conn)
|
|
if 'plugin_store' in set(inspector.get_table_names()):
|
|
return
|
|
op.create_table(
|
|
'plugin_store',
|
|
sa.Column('id', sa.Integer(), nullable=False),
|
|
sa.Column('plugin_slug', sa.String(length=128), nullable=False),
|
|
sa.Column('key', sa.String(length=255), nullable=False),
|
|
sa.Column('value', sa.JSON(), nullable=True),
|
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
|
sa.PrimaryKeyConstraint('id'),
|
|
sa.UniqueConstraint('plugin_slug', 'key', name='uq_plugin_store_slug_key'),
|
|
)
|
|
op.create_index('ix_plugin_store_plugin_slug', 'plugin_store', ['plugin_slug'])
|
|
|
|
|
|
def downgrade():
|
|
conn = op.get_bind()
|
|
inspector = sa.inspect(conn)
|
|
if 'plugin_store' not in set(inspector.get_table_names()):
|
|
return
|
|
op.drop_index('ix_plugin_store_plugin_slug', table_name='plugin_store')
|
|
op.drop_table('plugin_store')
|