Skip to content

feat: server-side client state persistence #8314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
26aab60
chore: bump version to v6.2.0
psychedelicious Jul 25, 2025
d6e0e43
perf(ui): imperatively get nodes and edges in autolayout hook
psychedelicious Jul 25, 2025
2bbfcc2
fix(ui): ensure all node context provider wraps all calls to useInvoc…
psychedelicious Jul 25, 2025
e653837
fix(ui): add separate wrapper components for notes and current image …
psychedelicious Jul 25, 2025
70ac58e
tidy(ui): remove unused props
psychedelicious Jul 25, 2025
28633c9
feat: server-side client state persistence
psychedelicious Jul 21, 2025
e872c25
refactor(ui): cleaner slice definitions
psychedelicious Jul 22, 2025
682d271
feat(api): make client state key query not body
psychedelicious Jul 22, 2025
6a70282
chore(ui): typegen
psychedelicious Jul 22, 2025
ca06847
refactor(ui): alternate approach to slice configs
psychedelicious Jul 22, 2025
456205d
refactor(ui): iterate on persistence
psychedelicious Jul 22, 2025
42f3990
refactor(ui): iterate on persistence
psychedelicious Jul 22, 2025
53bcbc5
chore(ui): lint
psychedelicious Jul 22, 2025
3cf8250
tests(app): service mocks
psychedelicious Jul 22, 2025
61e7116
chore: ruff
psychedelicious Jul 22, 2025
9492569
wip
psychedelicious Jul 23, 2025
6a9962d
git: update gitignore
psychedelicious Jul 24, 2025
ca25765
revert(ui): temp disable eslint rule
psychedelicious Jul 24, 2025
c44571b
revert(ui): temp changes to main.tsx for testing
psychedelicious Jul 24, 2025
e7c67da
refactor(ui): restructure persistence driver creation to support cust…
psychedelicious Jul 24, 2025
7e59d04
feat(ui): iterate on storage api
psychedelicious Jul 24, 2025
6962536
refactor(ui): use zod for all redux state (wip)
psychedelicious Jul 24, 2025
aed9b10
refactor(ui): use zod for all redux state
psychedelicious Jul 25, 2025
6ea4884
chore(ui): bump zod to latest
psychedelicious Jul 25, 2025
1addeb4
fix(ui): check initial retrieval and set as last persisted
psychedelicious Jul 25, 2025
ca7d7c9
refactor(ui): work around zod async validation issue
psychedelicious Jul 25, 2025
f3cd49d
refactor(ui): just manually validate async stuff
psychedelicious Jul 25, 2025
038b110
fix(ui): do not store whole model configs in state
psychedelicious Jul 25, 2025
af345a3
fix(ui): infinite loop when setting tile controlnet model
psychedelicious Jul 25, 2025
5a102f6
chore(ui): lint
psychedelicious Jul 25, 2025
afa1ee7
tidy(ui): enable devmode redux checks
psychedelicious Jul 25, 2025
afc6911
chore: bump version to v6.3.0a1
psychedelicious Jul 25, 2025
7da1411
Merge branch 'main' into psyche/feat/app/client-state-persistence
hipsterusername Jul 25, 2025
1cb4ef0
add newline
hipsterusername Jul 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions invokeai/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
from invokeai.app.services.boards.boards_default import BoardService
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.download.download_default import DownloadQueueService
from invokeai.app.services.events.events_fastapievents import FastAPIEventService
Expand Down Expand Up @@ -151,6 +152,7 @@ def initialize(
style_preset_records = SqliteStylePresetRecordsStorage(db=db)
style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder)
client_state_persistence = ClientStatePersistenceSqlite(db=db)

services = InvocationServices(
board_image_records=board_image_records,
Expand Down Expand Up @@ -181,6 +183,7 @@ def initialize(
style_preset_records=style_preset_records,
style_preset_image_files=style_preset_image_files,
workflow_thumbnails=workflow_thumbnails,
client_state_persistence=client_state_persistence,
)

ApiDependencies.invoker = Invoker(services)
Expand Down
51 changes: 49 additions & 2 deletions invokeai/app/api/routers/app_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from typing import Optional

import torch
from fastapi import Body
from fastapi import Body, HTTPException, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, JsonValue

from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.invocations.upscale import ESRGAN_MODELS
Expand Down Expand Up @@ -173,3 +173,50 @@ async def disable_invocation_cache() -> None:
async def get_invocation_cache_status() -> InvocationCacheStatus:
"""Clears the invocation cache"""
return ApiDependencies.invoker.services.invocation_cache.get_status()


@app_router.get(
"/client_state",
operation_id="get_client_state_by_key",
response_model=JsonValue | None,
)
async def get_client_state_by_key(
key: str = Query(..., description="Key to get"),
) -> JsonValue | None:
"""Gets the client state"""
try:
return ApiDependencies.invoker.services.client_state_persistence.get_by_key(key)
except Exception as e:
logging.error(f"Error getting client state: {e}")
raise HTTPException(status_code=500, detail="Error setting client state")


@app_router.post(
"/client_state",
operation_id="set_client_state",
response_model=None,
)
async def set_client_state(
key: str = Query(..., description="Key to set"),
value: JsonValue = Body(..., description="Value of the key"),
) -> None:
"""Sets the client state"""
try:
ApiDependencies.invoker.services.client_state_persistence.set_by_key(key, value)
except Exception as e:
logging.error(f"Error setting client state: {e}")
raise HTTPException(status_code=500, detail="Error setting client state")


@app_router.delete(
"/client_state",
operation_id="delete_client_state",
responses={204: {"description": "Client state deleted"}},
)
async def delete_client_state() -> None:
"""Deletes the client state"""
try:
ApiDependencies.invoker.services.client_state_persistence.delete()
except Exception as e:
logging.error(f"Error deleting client state: {e}")
raise HTTPException(status_code=500, detail="Error deleting client state")
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from abc import ABC, abstractmethod

from pydantic import JsonValue


class ClientStatePersistenceABC(ABC):
"""
Base class for client persistence implementations.
This class defines the interface for persisting client data.
"""

@abstractmethod
def set_by_key(self, key: str, value: JsonValue) -> None:
"""
Store the data for the client.

:param data: The client data to be stored.
"""
pass

@abstractmethod
def get_by_key(self, key: str) -> JsonValue | None:
"""
Get the data for the client.

:return: The client data.
"""
pass

@abstractmethod
def delete(self) -> None:
"""
Delete the data for the client.
"""
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import json

from pydantic import JsonValue

from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase


class ClientStatePersistenceSqlite(ClientStatePersistenceABC):
"""
Base class for client persistence implementations.
This class defines the interface for persisting client data.
"""

def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
self._db = db
self._default_row_id = 1

def start(self, invoker: Invoker) -> None:
self._invoker = invoker

def set_by_key(self, key: str, value: JsonValue) -> None:
state = self.get() or {}
state.update({key: value})

with self._db.transaction() as cursor:
cursor.execute(
f"""
INSERT INTO client_state (id, data)
VALUES ({self._default_row_id}, ?)
ON CONFLICT(id) DO UPDATE
SET data = excluded.data;
""",
(json.dumps(state),),
)

def get(self) -> dict[str, JsonValue] | None:
with self._db.transaction() as cursor:
cursor.execute(
f"""
SELECT data FROM client_state
WHERE id = {self._default_row_id}
"""
)
row = cursor.fetchone()
if row is None:
return None
return json.loads(row[0])

def get_by_key(self, key: str) -> JsonValue | None:
state = self.get()
if state is None:
return None
return state.get(key, None)

def delete(self) -> None:
with self._db.transaction() as cursor:
cursor.execute(
f"""
DELETE FROM client_state
WHERE id = {self._default_row_id}
"""
)
3 changes: 3 additions & 0 deletions invokeai/app/services/invocation_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
from invokeai.app.services.boards.boards_base import BoardServiceABC
from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.download import DownloadQueueServiceBase
from invokeai.app.services.events.events_base import EventServiceBase
Expand Down Expand Up @@ -73,6 +74,7 @@ def __init__(
style_preset_records: "StylePresetRecordsStorageBase",
style_preset_image_files: "StylePresetImageFileStorageBase",
workflow_thumbnails: "WorkflowThumbnailServiceBase",
client_state_persistence: "ClientStatePersistenceABC",
):
self.board_images = board_images
self.board_image_records = board_image_records
Expand Down Expand Up @@ -102,3 +104,4 @@ def __init__(
self.style_preset_records = style_preset_records
self.style_preset_image_files = style_preset_image_files
self.workflow_thumbnails = workflow_thumbnails
self.client_state_persistence = client_state_persistence
2 changes: 2 additions & 0 deletions invokeai/app/services/shared/sqlite/sqlite_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_18 import build_migration_18
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_19 import build_migration_19
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_20 import build_migration_20
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_21 import build_migration_21
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator


Expand Down Expand Up @@ -63,6 +64,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_18())
migrator.register_migration(build_migration_19(app_config=config))
migrator.register_migration(build_migration_20())
migrator.register_migration(build_migration_21())
migrator.run_migrations()

return db
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import sqlite3

from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration


class Migration21Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""
CREATE TABLE client_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
data TEXT NOT NULL, -- Frontend will handle the shape of this data
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
"""
)
cursor.execute(
"""
CREATE TRIGGER tg_client_state_updated_at
AFTER UPDATE ON client_state
FOR EACH ROW
BEGIN
UPDATE client_state
SET updated_at = CURRENT_TIMESTAMP
WHERE id = OLD.id;
END;
"""
)


def build_migration_21() -> Migration:
"""Builds the migration object for migrating from version 20 to version 21. This includes:
- Creating the `client_state` table.
- Adding a trigger to update the `updated_at` field on updates.
"""
return Migration(
from_version=20,
to_version=21,
callback=Migration21Callback(),
)
3 changes: 2 additions & 1 deletion invokeai/frontend/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ yalc.lock

# vitest
tsconfig.vitest-temp.json
coverage/
coverage/
*.tgz
2 changes: 1 addition & 1 deletion invokeai/frontend/web/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ i18n.use(initReactI18next).init({
returnNull: false,
});

const store = createStore(undefined, false);
const store = createStore({ driver: { getItem: () => {}, setItem: () => {} }, persistThrottle: 2000 });
$store.set(store);
$baseUrl.set('http://localhost:9090');

Expand Down
4 changes: 4 additions & 0 deletions invokeai/frontend/web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ export default [
importNames: ['isEqual'],
message: 'Please use objectEquals from @observ33r/object-equals instead.',
},
{
name: 'zod/v3',
message: 'Import from zod instead.',
},
],
},
],
Expand Down
3 changes: 1 addition & 2 deletions invokeai/frontend/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"framer-motion": "^11.10.0",
"i18next": "^25.3.2",
"i18next-http-backend": "^3.0.2",
"idb-keyval": "6.2.2",
"jsondiffpatch": "^0.7.3",
"konva": "^9.3.22",
"linkify-react": "^4.3.1",
Expand Down Expand Up @@ -103,7 +102,7 @@
"use-debounce": "^10.0.5",
"use-device-pixel-ratio": "^1.1.2",
"uuid": "^11.1.0",
"zod": "^4.0.5",
"zod": "^4.0.10",
"zod-validation-error": "^3.5.2"
},
"peerDependencies": {
Expand Down
Loading