Persistence¶
CascadeUI provides two persistence patterns through a single entry point: setup_persistence().
Setup¶
Call setup_persistence() once in your bot's setup_hook, after loading your cogs:
from cascadeui import setup_persistence
from cascadeui.persistence import SQLiteBackend
class MyBot(commands.Bot):
async def setup_hook(self):
# Load cogs first -- imports register PersistentView subclasses
await self.load_extension("cogs.dashboard")
await self.load_extension("cogs.counter")
# Then enable persistence
await setup_persistence(self, backend=SQLiteBackend("cascadeui.db"))
Cog loading order matters
setup_persistence must be called after all cogs are loaded. When Python imports a module containing a PersistentView subclass, __init_subclass__ registers it in the class registry. If setup_persistence runs first, the registry is empty and no views get restored.
With or Without bot¶
# Data-only persistence (no bot needed)
await setup_persistence(backend=SQLiteBackend("cascadeui.db"))
# Full persistence: data + view re-attachment
await setup_persistence(bot, backend=SQLiteBackend("cascadeui.db"))
- Without
bot: Enables the storage backend and restores state from disk. Views withstate_keycan look up their saved data when re-invoked. - With
bot: Does everything above, plus re-attachesPersistentViewinstances to their original Discord messages so they stay interactive after a restart.
Storage Backends¶
JSON File (built-in)¶
No extra dependencies. Good for development and small bots:
Before every save, a .bak backup is created for recovery.
SQLite (recommended)¶
Requires aiosqlite. Uses WAL mode for concurrent reads and avoids file locking issues on Windows:
from cascadeui.persistence import SQLiteBackend
await setup_persistence(bot, backend=SQLiteBackend("cascadeui.db"))
Redis¶
Requires redis (with async support). Useful for bots running across multiple processes or machines:
from cascadeui.persistence import RedisBackend
await setup_persistence(bot, backend=RedisBackend(url="redis://localhost"))
Custom Backend¶
Implement the StorageBackend interface:
class MyBackend:
async def save_state(self, state: dict) -> bool:
# Serialize and store. Return True on success.
...
async def load_state(self) -> dict:
# Load and deserialize. Return empty dict if no saved state.
...
Migrating Between Backends¶
Move state from one backend to another:
from cascadeui.persistence import migrate_storage, FileStorageBackend, SQLiteBackend
await migrate_storage(
source=FileStorageBackend("old_state.json"),
target=SQLiteBackend("cascadeui.db"),
)
Pattern 1: Data Persistence (re-invoke to restore)¶
Use any view with a state_key to persist data across view lifetimes. Works with both V1 and V2 views:
The view will timeout normally, but its data stays on disk. When the user runs the command again, the new view instance reads the saved data and picks up where they left off.
Key concepts:
state_keyprovides a stable identity for data lookup (unlikeself.idwhich is a new UUID each time)- Scope per-user with
state_key=f"counter:{user_id}", per-guild withstate_key=f"counter:{guild_id}", etc. - The view itself is recreated each time, only the data persists
Pattern 2: View Persistence (survive bot restarts)¶
Use PersistentView (V1) or PersistentLayoutView (V2) for views that stay interactive across bot restarts:
from cascadeui import PersistentLayoutView, StatefulButton, card, slugify
from discord.ui import ActionRow
class RoleSelectorPanel(PersistentLayoutView):
session_limit = 1
session_scope = "guild"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_item(
card(
"## Role Selector",
ActionRow(
StatefulButton(
label="Get Role",
custom_id="roles:get",
callback=self.give_role,
),
),
color=discord.Color.blurple(),
)
)
async def give_role(self, interaction):
...
async def on_restore(self, bot):
...
from cascadeui import PersistentView, StatefulButton
class RoleSelectorView(PersistentView):
session_limit = 1
session_scope = "guild"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_item(StatefulButton(
label="Get Role",
custom_id="roles:get",
callback=self.give_role,
))
async def give_role(self, interaction):
...
async def on_restore(self, bot):
...
Send it once (typically from an admin command):
@bot.hybrid_command()
async def setup_roles(ctx):
view = RoleSelectorPanel(context=ctx, state_key=f"roles:panel:{ctx.guild.id}")
await view.send()
After a bot restart, setup_persistence(bot) automatically:
- Reads the persistent view registry from saved state
- Looks up the
RoleSelectorViewclass by name - Fetches the original channel and message (skipping non-messageable channels)
- Creates a new view instance and attaches it via
bot.add_view(view, message_id=...) - Restores identity fields (
user_id,guild_id) from the saved entry so session limiting works correctly after restart - Calls
on_restore(bot)for any post-restore setup
Requirements for PersistentView:
state_keyis required (raisesValueErrorif not provided)- All components must have explicit
custom_idvalues (auto-generated IDs won't survive restarts) timeoutis forced toNone(persistent views never timeout)
owner_only defaults to False
StatefulView defaults to owner_only = True, meaning only the user who created the view can interact with it. PersistentView flips this to False because persistent views are typically shared panels (role selectors, ticket systems, dashboards) that any user should be able to use.
If your persistent view should be restricted to its creator, set it explicitly:
PersistentView cannot be ephemeral
PersistentView.send(ephemeral=True) raises ValueError. Ephemeral messages have no permanent message ID and cannot be re-attached after a bot restart. This is a hard constraint from Discord's API.
Stale Entry Handling¶
If things change while the bot is offline:
| Scenario | What happens |
|---|---|
| Message deleted | Entry removed from state, won't try again |
| Channel deleted | Entry removed from state, won't try again |
| Channel is non-messageable (e.g. category, forum) | Entry removed from state, won't try again |
| View class renamed/removed | Entry skipped but kept (in case the import is just missing temporarily) |
How It Works Under the Hood¶
When a PersistentView is sent, it dispatches a PERSISTENT_VIEW_REGISTERED action that stores:
state_key(lookup key)class_name(for reconstructing the view)message_id,channel_id,guild_id,user_id(for re-attaching and session indexing)
This gets persisted to disk along with all other state. On restart, setup_persistence reads this registry, rebuilds the views, and restores their identity fields so session limiting works correctly across restarts.
One message per state_key
The persistent view registry tracks one message per state_key. If you send a second view with the same state_key, the framework automatically exits the previous view instance (unsubscribing, unregistering, and disabling its components) and overwrites the registry entry. If the previous instance is no longer alive (e.g., from a prior bot session that wasn't restored), the old message's components are removed directly. Design your state_key values to be unique per intended instance (e.g., "roles:main" for a single panel, or f"profile:{user_id}" for per-user views).
The __init_subclass__ hook on PersistentView and PersistentLayoutView automatically registers every subclass in a shared class name -> class mapping. This is why cog loading order matters: the subclass must be imported (triggering __init_subclass__) before setup_persistence tries to look it up.
State Data Structure¶
The persisted state tree looks like this:
{
"views": {
"a1b2c3d4-...": {
"type": "CounterView",
"session_id": "CounterView:user_123",
"channel_id": 123456,
"message_id": 789012,
"user_id": 123,
"guild_id": 456
}
},
"sessions": {
"CounterView:user_123": {
"views": ["a1b2c3d4-..."],
"nav_stack": [],
"undo_stack": [],
"redo_stack": [],
"data": {}
}
},
"application": {
"counters": {"counter:123": 42},
"_scoped": {
"user:123": {"settings": {"theme": "dark"}}
}
},
"persistent_views": {
"roles:panel:456": {
"class_name": "RoleSelectorPanel",
"state_key": "roles:panel:456",
"message_id": 789012,
"channel_id": 123456,
"guild_id": 456,
"user_id": 123
}
}
}
views— active view instances (cleaned up on exit/timeout)sessions— user sessions with nav stacks and undo historyapplication— your custom state (counters, settings, etc.)persistent_views— registry for views that survive restarts