chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
@@ -0,0 +1,136 @@
import importlib
import inspect
import pkgutil
import re
from collections.abc import Callable
from dataclasses import dataclass
from logging import Logger
from types import ModuleType
from typing import Any
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
DEFAULT_MIGRATIONS_PACKAGE = "invokeai.app.services.shared.sqlite_migrator.migrations"
_MIGRATION_MODULE_RE = re.compile(r"^migration_(\d+)$")
_DATED_MIGRATION_MODULE_RE = re.compile(r"^migration_(\d{4}_\d{2}_\d{2}_[a-z0-9][a-z0-9_]*)$")
class MigrationLoaderError(RuntimeError):
"""Raised when migration discovery or construction fails."""
@dataclass(frozen=True)
class MigrationBuildContext:
app_config: InvokeAIAppConfig
logger: Logger
image_files: ImageFileStorageBase
def get_dependency(self, name: str) -> Any:
if name == "app_config":
return self.app_config
if name == "config":
return self.app_config
if name == "logger":
return self.logger
if name == "image_files":
return self.image_files
raise MigrationLoaderError(f"Migration builder requested unknown dependency '{name}'")
MigrationBuilder = Callable[..., Migration]
@dataclass(frozen=True)
class DiscoveredMigrationBuilder:
module_name: str
sort_key: tuple[int, int | str]
expected_migration_id: str
builder: MigrationBuilder
def discover_migration_builders(package_name: str = DEFAULT_MIGRATIONS_PACKAGE) -> list[DiscoveredMigrationBuilder]:
try:
package = importlib.import_module(package_name)
except Exception as e:
raise MigrationLoaderError(f"Unable to import migrations package '{package_name}': {e}") from e
package_path = getattr(package, "__path__", None)
if package_path is None:
raise MigrationLoaderError(f"Migrations package '{package_name}' is not a package")
discovered: list[DiscoveredMigrationBuilder] = []
for module_info in pkgutil.iter_modules(package_path):
module_short_name = module_info.name
if not module_short_name.startswith("migration_"):
continue
numeric_match = _MIGRATION_MODULE_RE.match(module_short_name)
dated_match = _DATED_MIGRATION_MODULE_RE.match(module_short_name)
if numeric_match is None and dated_match is None:
raise MigrationLoaderError(f"Malformed migration module name '{module_short_name}'")
if numeric_match is not None:
sort_key: tuple[int, int | str] = (0, int(numeric_match.group(1)))
expected_builder_name = f"build_migration_{numeric_match.group(1)}"
expected_migration_id = f"migration_{numeric_match.group(1)}"
else:
sort_key = (1, module_short_name)
expected_builder_name = "build_migration"
expected_migration_id = module_short_name.removeprefix("migration_")
full_module_name = f"{package_name}.{module_short_name}"
try:
module = importlib.import_module(full_module_name)
except Exception as e:
raise MigrationLoaderError(f"Unable to import migration module '{full_module_name}': {e}") from e
builder = _get_builder(module, expected_builder_name)
discovered.append(DiscoveredMigrationBuilder(full_module_name, sort_key, expected_migration_id, builder))
return sorted(discovered, key=lambda discovered_builder: discovered_builder.sort_key)
def build_migrations(context: MigrationBuildContext, package_name: str = DEFAULT_MIGRATIONS_PACKAGE) -> list[Migration]:
migrations: list[Migration] = []
for discovered_builder in discover_migration_builders(package_name):
kwargs = _get_builder_kwargs(discovered_builder.builder, context, discovered_builder.module_name)
migration = discovered_builder.builder(**kwargs)
if not isinstance(migration, Migration):
raise MigrationLoaderError(
f"Migration builder '{discovered_builder.builder.__name__}' in "
f"'{discovered_builder.module_name}' must return Migration"
)
if migration.id != discovered_builder.expected_migration_id:
raise MigrationLoaderError(
f"Migration builder '{discovered_builder.builder.__name__}' in "
f"'{discovered_builder.module_name}' must return migration id "
f"'{discovered_builder.expected_migration_id}', got '{migration.id}'"
)
migrations.append(migration)
return migrations
def _get_builder(module: ModuleType, builder_name: str) -> MigrationBuilder:
builder = getattr(module, builder_name, None)
if builder is None:
raise MigrationLoaderError(f"Migration module '{module.__name__}' must define '{builder_name}'")
if not callable(builder):
raise MigrationLoaderError(f"Migration builder '{module.__name__}.{builder_name}' is not callable")
return builder
def _get_builder_kwargs(builder: MigrationBuilder, context: MigrationBuildContext, module_name: str) -> dict[str, Any]:
signature = inspect.signature(builder)
kwargs: dict[str, Any] = {}
for parameter in signature.parameters.values():
if parameter.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
raise MigrationLoaderError(
f"Migration builder '{module_name}.{builder.__name__}' must not use *args or **kwargs"
)
if parameter.kind == inspect.Parameter.POSITIONAL_ONLY:
raise MigrationLoaderError(
f"Migration builder '{module_name}.{builder.__name__}' must not use positional-only parameters"
)
kwargs[parameter.name] = context.get_dependency(parameter.name)
return kwargs
@@ -0,0 +1,374 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration1Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
"""Migration callback for database version 1."""
self._create_board_images(cursor)
self._create_boards(cursor)
self._create_images(cursor)
self._create_model_config(cursor)
self._create_session_queue(cursor)
self._create_workflow_images(cursor)
self._create_workflows(cursor)
def _create_board_images(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `board_images` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS board_images (
board_id TEXT NOT NULL,
image_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
-- enforce one-to-many relationship between boards and images using PK
-- (we can extend this to many-to-many later)
PRIMARY KEY (image_name),
FOREIGN KEY (board_id) REFERENCES boards (board_id) ON DELETE CASCADE,
FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_board_images_board_id ON board_images (board_id);",
"CREATE INDEX IF NOT EXISTS idx_board_images_board_id_created_at ON board_images (board_id, created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_board_images_updated_at
AFTER UPDATE
ON board_images FOR EACH ROW
BEGIN
UPDATE board_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE board_id = old.board_id AND image_name = old.image_name;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_boards(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `boards` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS boards (
board_id TEXT NOT NULL PRIMARY KEY,
board_name TEXT NOT NULL,
cover_image_name TEXT,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
FOREIGN KEY (cover_image_name) REFERENCES images (image_name) ON DELETE SET NULL
);
"""
]
indices = ["CREATE INDEX IF NOT EXISTS idx_boards_created_at ON boards (created_at);"]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_boards_updated_at
AFTER UPDATE
ON boards FOR EACH ROW
BEGIN
UPDATE boards SET updated_at = current_timestamp
WHERE board_id = old.board_id;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_images(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `images` table, indices and triggers. Adds the `starred` column."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS images (
image_name TEXT NOT NULL PRIMARY KEY,
-- This is an enum in python, unrestricted string here for flexibility
image_origin TEXT NOT NULL,
-- This is an enum in python, unrestricted string here for flexibility
image_category TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
session_id TEXT,
node_id TEXT,
metadata TEXT,
is_intermediate BOOLEAN DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME
);
"""
]
indices = [
"CREATE UNIQUE INDEX IF NOT EXISTS idx_images_image_name ON images(image_name);",
"CREATE INDEX IF NOT EXISTS idx_images_image_origin ON images(image_origin);",
"CREATE INDEX IF NOT EXISTS idx_images_image_category ON images(image_category);",
"CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_images_updated_at
AFTER UPDATE
ON images FOR EACH ROW
BEGIN
UPDATE images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE image_name = old.image_name;
END;
"""
]
# Add the 'starred' column to `images` if it doesn't exist
cursor.execute("PRAGMA table_info(images)")
columns = [column[1] for column in cursor.fetchall()]
if "starred" not in columns:
tables.append("ALTER TABLE images ADD COLUMN starred BOOLEAN DEFAULT FALSE;")
indices.append("CREATE INDEX IF NOT EXISTS idx_images_starred ON images(starred);")
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_model_config(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `model_config` table, `model_manager_metadata` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS model_config (
id TEXT NOT NULL PRIMARY KEY,
-- The next 3 fields are enums in python, unrestricted string here
base TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
original_hash TEXT, -- could be null
-- Serialized JSON representation of the whole config object,
-- which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
""",
"""--sql
CREATE TABLE IF NOT EXISTS model_manager_metadata (
metadata_key TEXT NOT NULL PRIMARY KEY,
metadata_value TEXT NOT NULL
);
""",
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS model_config_updated_at
AFTER UPDATE
ON model_config FOR EACH ROW
BEGIN
UPDATE model_config SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS base_index ON model_config(base);",
"CREATE INDEX IF NOT EXISTS type_index ON model_config(type);",
"CREATE INDEX IF NOT EXISTS name_index ON model_config(name);",
"CREATE UNIQUE INDEX IF NOT EXISTS path_index ON model_config(path);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_session_queue(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS session_queue (
item_id INTEGER PRIMARY KEY AUTOINCREMENT, -- used for ordering, cursor pagination
batch_id TEXT NOT NULL, -- identifier of the batch this queue item belongs to
queue_id TEXT NOT NULL, -- identifier of the queue this queue item belongs to
session_id TEXT NOT NULL UNIQUE, -- duplicated data from the session column, for ease of access
field_values TEXT, -- NULL if no values are associated with this queue item
session TEXT NOT NULL, -- the session to be executed
status TEXT NOT NULL DEFAULT 'pending', -- the status of the queue item, one of 'pending', 'in_progress', 'completed', 'failed', 'canceled'
priority INTEGER NOT NULL DEFAULT 0, -- the priority, higher is more important
error TEXT, -- any errors associated with this queue item
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), -- updated via trigger
started_at DATETIME, -- updated via trigger
completed_at DATETIME -- updated via trigger, completed items are cleaned up on application startup
-- Ideally this is a FK, but graph_executions uses INSERT OR REPLACE, and REPLACE triggers the ON DELETE CASCADE...
-- FOREIGN KEY (session_id) REFERENCES graph_executions (id) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_item_id ON session_queue(item_id);",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_session_id ON session_queue(session_id);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_batch_id ON session_queue(batch_id);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_created_priority ON session_queue(priority);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_created_status ON session_queue(status);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_completed_at
AFTER UPDATE OF status ON session_queue
FOR EACH ROW
WHEN
NEW.status = 'completed'
OR NEW.status = 'failed'
OR NEW.status = 'canceled'
BEGIN
UPDATE session_queue
SET completed_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = NEW.item_id;
END;
""",
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_started_at
AFTER UPDATE OF status ON session_queue
FOR EACH ROW
WHEN
NEW.status = 'in_progress'
BEGIN
UPDATE session_queue
SET started_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = NEW.item_id;
END;
""",
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_updated_at
AFTER UPDATE
ON session_queue FOR EACH ROW
BEGIN
UPDATE session_queue
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = old.item_id;
END;
""",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_workflow_images(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflow_images (
workflow_id TEXT NOT NULL,
image_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
-- enforce one-to-many relationship between workflows and images using PK
-- (we can extend this to many-to-many later)
PRIMARY KEY (image_name),
FOREIGN KEY (workflow_id) REFERENCES workflows (workflow_id) ON DELETE CASCADE,
FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id ON workflow_images (workflow_id);",
"CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id_created_at ON workflow_images (workflow_id, created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflow_images_updated_at
AFTER UPDATE
ON workflow_images FOR EACH ROW
BEGIN
UPDATE workflow_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id AND image_name = old.image_name;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_workflows(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflows (
workflow TEXT NOT NULL,
workflow_id TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.id')) VIRTUAL NOT NULL UNIQUE, -- gets implicit index
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) -- updated via trigger
);
"""
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflows_updated_at
AFTER UPDATE
ON workflows FOR EACH ROW
BEGIN
UPDATE workflows
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id;
END;
"""
]
for stmt in tables + triggers:
cursor.execute(stmt)
def build_migration_1() -> Migration:
"""
Builds the migration from database version 0 (init) to 1.
This migration represents the state of the database circa InvokeAI v3.4.0, which was the last
version to not use migrations to manage the database.
As such, this migration does include some ALTER statements, and the SQL statements are written
to be idempotent.
- Create `board_images` junction table
- Create `boards` table
- Create `images` table, add `starred` column
- Create `model_config` table
- Create `session_queue` table
- Create `workflow_images` junction table
- Create `workflows` table
"""
migration_1 = Migration(
id="migration_1",
depends_on=None,
from_version=0,
to_version=1,
callback=Migration1Callback(),
)
return migration_1
@@ -0,0 +1,37 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration10Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_error_cols(cursor)
def _update_error_cols(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `error_type` and `error_message` columns to the session queue table.
- Renames the `error` column to `error_traceback`.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN error_type TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN error_message TEXT;")
cursor.execute("ALTER TABLE session_queue RENAME COLUMN error TO error_traceback;")
def build_migration_10() -> Migration:
"""
Build the migration from database version 9 to 10.
This migration does the following:
- Adds `error_type` and `error_message` columns to the session queue table.
- Renames the `error` column to `error_traceback`.
"""
migration_10 = Migration(
id="migration_10",
depends_on="migration_9",
from_version=9,
to_version=10,
callback=Migration10Callback(),
)
return migration_10
@@ -0,0 +1,77 @@
import shutil
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
LEGACY_CORE_MODELS = [
# OpenPose
"any/annotators/dwpose/yolox_l.onnx",
"any/annotators/dwpose/dw-ll_ucoco_384.onnx",
# DepthAnything
"any/annotators/depth_anything/depth_anything_vitl14.pth",
"any/annotators/depth_anything/depth_anything_vitb14.pth",
"any/annotators/depth_anything/depth_anything_vits14.pth",
# Lama inpaint
"core/misc/lama/lama.pt",
# RealESRGAN upscale
"core/upscaling/realesrgan/RealESRGAN_x4plus.pth",
"core/upscaling/realesrgan/RealESRGAN_x4plus_anime_6B.pth",
"core/upscaling/realesrgan/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth",
"core/upscaling/realesrgan/RealESRGAN_x2plus.pth",
]
class Migration11Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._remove_convert_cache()
self._remove_downloaded_models()
self._remove_unused_core_models()
def _remove_convert_cache(self) -> None:
"""Rename models/.cache to models/.convert_cache."""
self._logger.info("Removing models/.cache directory. Converted models will now be cached in .convert_cache.")
legacy_convert_path = self._app_config.root_path / "models" / ".cache"
shutil.rmtree(legacy_convert_path, ignore_errors=True)
def _remove_downloaded_models(self) -> None:
"""Remove models from their old locations; they will re-download when needed."""
self._logger.info(
"Removing legacy just-in-time models. Downloaded models will now be cached in .download_cache."
)
for model_path in LEGACY_CORE_MODELS:
legacy_dest_path = self._app_config.models_path / model_path
legacy_dest_path.unlink(missing_ok=True)
def _remove_unused_core_models(self) -> None:
"""Remove unused core models and their directories."""
self._logger.info("Removing defunct core models.")
for dir in ["face_restoration", "misc", "upscaling"]:
path_to_remove = self._app_config.models_path / "core" / dir
shutil.rmtree(path_to_remove, ignore_errors=True)
shutil.rmtree(self._app_config.models_path / "any" / "annotators", ignore_errors=True)
def build_migration_11(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""
Build the migration from database version 10 to 11.
This migration does the following:
- Moves "core" models previously downloaded with download_with_progress_bar() into new
"models/.download_cache" directory.
- Renames "models/.cache" to "models/.convert_cache".
"""
migration_11 = Migration(
id="migration_11",
depends_on="migration_10",
from_version=10,
to_version=11,
callback=Migration11Callback(app_config=app_config, logger=logger),
)
return migration_11
@@ -0,0 +1,37 @@
import shutil
import sqlite3
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration12Callback:
def __init__(self, app_config: InvokeAIAppConfig) -> None:
self._app_config = app_config
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._remove_model_convert_cache_dir()
def _remove_model_convert_cache_dir(self) -> None:
"""
Removes unused model convert cache directory
"""
convert_cache = self._app_config.convert_cache_path
shutil.rmtree(convert_cache, ignore_errors=True)
def build_migration_12(app_config: InvokeAIAppConfig) -> Migration:
"""
Build the migration from database version 11 to 12.
This migration removes the now-unused model convert cache directory.
"""
migration_12 = Migration(
id="migration_12",
depends_on="migration_11",
from_version=11,
to_version=12,
callback=Migration12Callback(app_config),
)
return migration_12
@@ -0,0 +1,33 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration13Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_archived_col(cursor)
def _add_archived_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `archived` columns to the board table.
"""
cursor.execute("ALTER TABLE boards ADD COLUMN archived BOOLEAN DEFAULT FALSE;")
def build_migration_13() -> Migration:
"""
Build the migration from database version 12 to 13..
This migration does the following:
- Adds `archived` columns to the board table.
"""
migration_13 = Migration(
id="migration_13",
depends_on="migration_12",
from_version=12,
to_version=13,
callback=Migration13Callback(),
)
return migration_13
@@ -0,0 +1,63 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration14Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_style_presets(cursor)
def _create_style_presets(self, cursor: sqlite3.Cursor) -> None:
"""Create the table used to store style presets."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS style_presets (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
preset_data TEXT NOT NULL,
type TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS style_presets
AFTER UPDATE
ON style_presets FOR EACH ROW
BEGIN
UPDATE style_presets SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS idx_style_presets_name ON style_presets(name);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def build_migration_14() -> Migration:
"""
Build the migration from database version 13 to 14..
This migration does the following:
- Create the table used to store style presets.
"""
migration_14 = Migration(
id="migration_14",
depends_on="migration_13",
from_version=13,
to_version=14,
callback=Migration14Callback(),
)
return migration_14
@@ -0,0 +1,36 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration15Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_origin_col(cursor)
def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
def build_migration_15() -> Migration:
"""
Build the migration from database version 14 to 15.
This migration does the following:
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
migration_15 = Migration(
id="migration_15",
depends_on="migration_14",
from_version=14,
to_version=15,
callback=Migration15Callback(),
)
return migration_15
@@ -0,0 +1,33 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration16Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_retried_from_item_id_col(cursor)
def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `retried_from_item_id` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
def build_migration_16() -> Migration:
"""
Build the migration from database version 15 to 16.
This migration does the following:
- Adds `retried_from_item_id` column to the session queue table.
"""
migration_16 = Migration(
id="migration_16",
depends_on="migration_15",
from_version=15,
to_version=16,
callback=Migration16Callback(),
)
return migration_16
@@ -0,0 +1,37 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration17Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_workflows_tags_col(cursor)
def _add_workflows_tags_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
workflow JSON.
"""
cursor.execute(
"ALTER TABLE workflow_library ADD COLUMN tags TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.tags')) VIRTUAL;"
)
def build_migration_17() -> Migration:
"""
Build the migration from database version 16 to 17.
This migration does the following:
- Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
workflow JSON.
"""
migration_17 = Migration(
id="migration_17",
depends_on="migration_16",
from_version=16,
to_version=17,
callback=Migration17Callback(),
)
return migration_17
@@ -0,0 +1,49 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration18Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._make_workflow_opened_at_nullable(cursor)
def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
"""
Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
- Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
# For index renaming in SQLite, we need to drop and recreate
cursor.execute("DROP INDEX IF EXISTS idx_workflow_library_opened_at;")
# Rename existing column to deprecated
cursor.execute("ALTER TABLE workflow_library DROP COLUMN opened_at;")
# Add new nullable column - all values will be NULL - no migration of data needed
cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
# Create new index on the new column
cursor.execute(
"CREATE INDEX idx_workflow_library_opened_at ON workflow_library(opened_at);",
)
def build_migration_18() -> Migration:
"""
Build the migration from database version 17 to 18.
This migration does the following:
- Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
- Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
migration_18 = Migration(
id="migration_18",
depends_on="migration_17",
from_version=17,
to_version=18,
callback=Migration18Callback(),
)
return migration_18
@@ -0,0 +1,39 @@
import sqlite3
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
class Migration19Callback:
def __init__(self, app_config: InvokeAIAppConfig):
self.models_path = app_config.models_path
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._populate_size(cursor)
self._add_size_column(cursor)
def _add_size_column(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"ALTER TABLE models ADD COLUMN file_size INTEGER "
"GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL"
)
def _populate_size(self, cursor: sqlite3.Cursor) -> None:
all_models = cursor.execute("SELECT id, path FROM models;").fetchall()
for model_id, model_path in all_models:
mod = ModelOnDisk(self.models_path / model_path)
cursor.execute(
"UPDATE models SET config = json_set(config, '$.file_size', ?) WHERE id = ?", (mod.size(), model_id)
)
def build_migration_19(app_config: InvokeAIAppConfig) -> Migration:
return Migration(
id="migration_19",
depends_on="migration_18",
from_version=18,
to_version=19,
callback=Migration19Callback(app_config),
)
@@ -0,0 +1,168 @@
import sqlite3
from logging import Logger
from pydantic import ValidationError
from tqdm import tqdm
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.app.services.workflow_records.workflow_records_common import (
UnsafeWorkflowWithVersionValidator,
)
class Migration2Callback:
def __init__(self, image_files: ImageFileStorageBase, logger: Logger):
self._image_files = image_files
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor):
self._add_images_has_workflow(cursor)
self._add_session_queue_workflow(cursor)
self._drop_old_workflow_tables(cursor)
self._add_workflow_library(cursor)
self._drop_model_manager_metadata(cursor)
self._migrate_embedded_workflows(cursor)
def _add_images_has_workflow(self, cursor: sqlite3.Cursor) -> None:
"""Add the `has_workflow` column to `images` table."""
cursor.execute("PRAGMA table_info(images)")
columns = [column[1] for column in cursor.fetchall()]
if "has_workflow" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN has_workflow BOOLEAN DEFAULT FALSE;")
def _add_session_queue_workflow(self, cursor: sqlite3.Cursor) -> None:
"""Add the `workflow` column to `session_queue` table."""
cursor.execute("PRAGMA table_info(session_queue)")
columns = [column[1] for column in cursor.fetchall()]
if "workflow" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow TEXT;")
def _drop_old_workflow_tables(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `workflows` and `workflow_images` tables."""
cursor.execute("DROP TABLE IF EXISTS workflow_images;")
cursor.execute("DROP TABLE IF EXISTS workflows;")
def _add_workflow_library(self, cursor: sqlite3.Cursor) -> None:
"""Adds the `workflow_library` table and drops the `workflows` and `workflow_images` tables."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflow_library (
workflow_id TEXT NOT NULL PRIMARY KEY,
workflow TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated manually when retrieving workflow
opened_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Generated columns, needed for indexing and searching
category TEXT GENERATED ALWAYS as (json_extract(workflow, '$.meta.category')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(workflow, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(workflow, '$.description')) VIRTUAL NOT NULL
);
""",
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_workflow_library_created_at ON workflow_library(created_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_updated_at ON workflow_library(updated_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_opened_at ON workflow_library(opened_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_category ON workflow_library(category);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_name ON workflow_library(name);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_description ON workflow_library(description);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflow_library_updated_at
AFTER UPDATE
ON workflow_library FOR EACH ROW
BEGIN
UPDATE workflow_library
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `model_manager_metadata` table."""
cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;")
def _migrate_embedded_workflows(self, cursor: sqlite3.Cursor) -> None:
"""
In the v3.5.0 release, InvokeAI changed how it handles embedded workflows. The `images` table in
the database now has a `has_workflow` column, indicating if an image has a workflow embedded.
This migrate callback checks each image for the presence of an embedded workflow, then updates its entry
in the database accordingly.
"""
# Get all image names
cursor.execute("SELECT image_name FROM images")
image_names: list[str] = [image[0] for image in cursor.fetchall()]
total_image_names = len(image_names)
if not total_image_names:
return
self._logger.info(f"Migrating workflows for {total_image_names} images")
# Migrate the images
to_migrate: list[tuple[bool, str]] = []
pbar = tqdm(image_names)
for idx, image_name in enumerate(pbar):
pbar.set_description(f"Checking image {idx + 1}/{total_image_names} for workflow")
try:
pil_image = self._image_files.get(image_name)
except ImageFileNotFoundException:
self._logger.warning(f"Image {image_name} not found, skipping")
continue
except Exception as e:
self._logger.warning(f"Error while checking image {image_name}, skipping: {e}")
continue
if "invokeai_workflow" in pil_image.info:
try:
UnsafeWorkflowWithVersionValidator.validate_json(pil_image.info.get("invokeai_workflow", ""))
except ValidationError:
self._logger.warning(f"Image {image_name} has invalid embedded workflow, skipping")
continue
to_migrate.append((True, image_name))
self._logger.info(f"Adding {len(to_migrate)} embedded workflows to database")
cursor.executemany("UPDATE images SET has_workflow = ? WHERE image_name = ?", to_migrate)
def build_migration_2(image_files: ImageFileStorageBase, logger: Logger) -> Migration:
"""
Builds the migration from database version 1 to 2.
Introduced in v3.5.0 for the new workflow library.
:param image_files: The image files service, used to check for embedded workflows
:param logger: The logger, used to log progress during embedded workflows handling
This migration does the following:
- Add `has_workflow` column to `images` table
- Add `workflow` column to `session_queue` table
- Drop `workflows` and `workflow_images` tables
- Add `workflow_library` table
- Drops the `model_manager_metadata` table
- Drops the `model_config` table, recreating it (at this point, there is no user data in this table)
- Populates the `has_workflow` column in the `images` table (requires `image_files` & `logger` dependencies)
"""
migration_2 = Migration(
id="migration_2",
depends_on="migration_1",
from_version=1,
to_version=2,
callback=Migration2Callback(image_files=image_files, logger=logger),
)
return migration_2
@@ -0,0 +1,39 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration20Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""
-- many-to-many relationship table for models
CREATE TABLE IF NOT EXISTS model_relationships (
-- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
model_key_1 TEXT NOT NULL,
model_key_2 TEXT NOT NULL,
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (model_key_1, model_key_2),
-- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
);
"""
)
cursor.execute(
"""
-- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
ON model_relationships(model_key_2)
"""
)
def build_migration_20() -> Migration:
return Migration(
id="migration_20",
depends_on="migration_19",
from_version=19,
to_version=20,
callback=Migration20Callback(),
)
@@ -0,0 +1,53 @@
"""Add workflow-call relationship columns to session_queue."""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class AddWorkflowCallQueueMetadataCallback:
"""Add durable parent/child workflow-call relationship columns to session_queue."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "workflow_call_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_id TEXT;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_id ON session_queue(workflow_call_id);"
)
if "parent_item_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_item_id INTEGER;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_parent_item_id ON session_queue(parent_item_id);"
)
if "parent_session_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_session_id TEXT;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_parent_session_id ON session_queue(parent_session_id);"
)
if "root_item_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN root_item_id INTEGER;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_root_item_id ON session_queue(root_item_id);")
if "workflow_call_depth" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_depth INTEGER;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_depth ON session_queue(workflow_call_depth);"
)
def build_migration() -> Migration:
return Migration(
id="2026_07_01_add_workflow_call_queue_metadata",
depends_on="migration_33",
callback=AddWorkflowCallQueueMetadataCallback(),
)
@@ -0,0 +1,60 @@
"""Add indexes supporting round-robin dequeue.
The round-robin dequeue in multiuser mode relies on two access shapes:
1. A per-user "best pending item" selection (``status = 'pending'`` partitioned by
``user_id`` and ordered by ``priority DESC, item_id ASC``).
2. A per-candidate "last served" lookup (``MAX(started_at)`` for a single ``user_id``),
evaluated as a correlated subquery once per user that has pending work.
With only the pre-existing single-column indexes on ``status``, ``priority``, and
``user_id``, the pending selection falls back to scanning the table and the last-served
lookup scans all retained history. Because completed/failed/canceled history is retained
(and ``max_queue_history`` defaults to unbounded), that cost would grow with total queue
history rather than with the number of pending items / active users.
``idx_session_queue_round_robin_pending`` lets the planner satisfy the pending selection
without touching historical rows. ``idx_session_queue_user_started_at`` turns each
``MAX(started_at) WHERE user_id = ?`` into an indexed seek (the planner's min/max
optimization reads the tail of the user's index range) rather than a scan, so the
last-served lookup costs ``O(log n)`` per active user instead of scanning history.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class RoundRobinIndexesCallback:
"""Add composite indexes matching the round-robin dequeue query shapes."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
# Pending-item selection: WHERE status = 'pending', PARTITION BY user_id,
# ORDER BY priority DESC, item_id ASC.
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_session_queue_round_robin_pending
ON session_queue (status, user_id, priority DESC, item_id ASC);
"""
)
# Last-served lookup: MAX(started_at) WHERE user_id = ?)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_session_queue_user_started_at
ON session_queue (user_id, started_at);
"""
)
def build_migration() -> Migration:
return Migration(
id="2026_07_03_round_robin_indexes",
# migration_27 added the user_id column to session_queue, which both indexes cover.
depends_on="migration_27",
callback=RoundRobinIndexesCallback(),
)
@@ -0,0 +1,42 @@
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(
id="migration_21",
depends_on="migration_20",
from_version=20,
to_version=21,
callback=Migration21Callback(),
)
@@ -0,0 +1,91 @@
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration22Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._logger.info("Removing UNIQUE(name, base, type) constraint from models table")
# Step 1: Rename the existing models table
cursor.execute("ALTER TABLE models RENAME TO models_old;")
# Step 2: Create the new models table without the UNIQUE(name, base, type) constraint
cursor.execute(
"""--sql
CREATE TABLE models (
id TEXT NOT NULL PRIMARY KEY,
hash TEXT GENERATED ALWAYS as (json_extract(config, '$.hash')) VIRTUAL NOT NULL,
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(config, '$.description')) VIRTUAL,
source TEXT GENERATED ALWAYS as (json_extract(config, '$.source')) VIRTUAL NOT NULL,
source_type TEXT GENERATED ALWAYS as (json_extract(config, '$.source_type')) VIRTUAL NOT NULL,
source_api_response TEXT GENERATED ALWAYS as (json_extract(config, '$.source_api_response')) VIRTUAL,
trigger_phrases TEXT GENERATED ALWAYS as (json_extract(config, '$.trigger_phrases')) VIRTUAL,
file_size INTEGER GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL,
-- Serialized JSON representation of the whole config object, which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Explicit unique constraint on path
UNIQUE(path)
);
"""
)
# Step 3: Copy all data from the old table to the new table
# Only copy the stored columns (id, config, created_at, updated_at), not the virtual columns
cursor.execute(
"INSERT INTO models (id, config, created_at, updated_at) "
"SELECT id, config, created_at, updated_at FROM models_old;"
)
# Step 4: Drop the old table
cursor.execute("DROP TABLE models_old;")
# Step 5: Recreate indexes
cursor.execute("CREATE INDEX IF NOT EXISTS base_index ON models(base);")
cursor.execute("CREATE INDEX IF NOT EXISTS type_index ON models(type);")
cursor.execute("CREATE INDEX IF NOT EXISTS name_index ON models(name);")
# Step 6: Recreate the updated_at trigger
cursor.execute(
"""--sql
CREATE TRIGGER models_updated_at
AFTER UPDATE
ON models FOR EACH ROW
BEGIN
UPDATE models SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_22(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 21 to version 22.
This migration:
- Removes the UNIQUE constraint on the combination of (base, name, type) columns in the models table
- Adds an explicit UNIQUE contraint on the path column
"""
return Migration(
id="migration_22",
depends_on="migration_21",
from_version=21,
to_version=22,
callback=Migration22Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,195 @@
import json
import sqlite3
from copy import deepcopy
from logging import Logger
from typing import Any
from pydantic import ValidationError
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.configs.factory import AnyModelConfig, AnyModelConfigValidator
from invokeai.backend.model_manager.configs.unknown import Unknown_Config
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ClipVariantType,
FluxVariantType,
ModelFormat,
ModelType,
ModelVariantType,
SchedulerPredictionType,
)
class Migration23Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
# Grab all model records
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_count = 0
fallback_count = 0
for model_id, config_json in rows:
try:
# Migrate the config JSON to the latest schema
config_dict: dict[str, Any] = json.loads(config_json)
migrated_config = self._parse_and_migrate_config(config_dict)
if isinstance(migrated_config, Unknown_Config):
fallback_count += 1
else:
migrated_count += 1
# Write the migrated config back to the database
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(migrated_config.model_dump_json(), model_id),
)
except ValidationError as e:
self._logger.error("Invalid config schema for model %s: %s", model_id, e)
raise
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
if migrated_count > 0 and fallback_count == 0:
self._logger.info(f"Migration complete: {migrated_count} model configs migrated")
elif migrated_count > 0 and fallback_count > 0:
self._logger.warning(
f"Migration complete: {migrated_count} model configs migrated, "
f"{fallback_count} model configs could not be migrated and were saved as unknown models",
)
elif migrated_count == 0 and fallback_count > 0:
self._logger.warning(
f"Migration complete: all {fallback_count} model configs could not be migrated and were saved as unknown models",
)
else:
self._logger.info("Migration complete: no model configs needed migration")
def _parse_and_migrate_config(self, config_dict: dict[str, Any]) -> AnyModelConfig:
# In v6.9.0 we made some improvements to the model taxonomy and the model config schemas. There are a changes
# we need to make to old configs to bring them up to date.
type = config_dict.get("type")
format = config_dict.get("format")
base = config_dict.get("base")
if base == BaseModelType.Flux.value and type == ModelType.Main.value:
# Prior to v6.9.0, we used an awkward combination of `config_path` and `variant` to distinguish between FLUX
# variants.
#
# `config_path` was set to one of:
# - flux-dev
# - flux-dev-fill
# - flux-schnell
#
# `variant` was set to ModelVariantType.Inpaint for FLUX Fill models and ModelVariantType.Normal for all other FLUX
# models.
#
# We now use the `variant` field to directly represent the FLUX variant type, and `config_path` is no longer used.
# Extract and remove `config_path` if present.
config_path = config_dict.pop("config_path", None)
match config_path:
case "flux-dev":
config_dict["variant"] = FluxVariantType.Dev.value
case "flux-dev-fill":
config_dict["variant"] = FluxVariantType.DevFill.value
case "flux-schnell":
config_dict["variant"] = FluxVariantType.Schnell.value
case _:
# Unknown config_path - default to Dev variant
config_dict["variant"] = FluxVariantType.Dev.value
if (
base
in {
BaseModelType.StableDiffusion1.value,
BaseModelType.StableDiffusion2.value,
BaseModelType.StableDiffusionXL.value,
BaseModelType.StableDiffusionXLRefiner.value,
}
and type == ModelType.Main.value
):
# Prior to v6.9.0, the prediction_type field was optional and would default to Epsilon if not present.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# Epsilon, matching the probe logic.
#
# It's only on SD1.x, SD2.x, and SDXL main models.
config_dict["prediction_type"] = config_dict.get("prediction_type", SchedulerPredictionType.Epsilon.value)
# Prior to v6.9.0, the variant field was optional and would default to Normal if not present.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# Normal. It's only on SD main models.
config_dict["variant"] = config_dict.get("variant", ModelVariantType.Normal.value)
if base == BaseModelType.Flux.value and type == ModelType.LoRA.value and format == ModelFormat.Diffusers.value:
# Prior to v6.9.0, we used the Diffusers format for FLUX LoRA models that used the diffusers _key_
# structure. This was misleading, as everywhere else in the application, we used the Diffusers format
# to indicate that the model files were in the Diffusers _file_ format (i.e. a directory containing
# the weights and config files).
#
# At runtime, we check the LoRA's state dict directly to determine the key structure, so we do not need
# to rely on the format field for this purpose. As of v6.9.0, we always use the LyCORIS format for single-
# file LoRAs, regardless of the key structure.
#
# This change allows LoRA model identification to not need a special case for FLUX LoRAs in the diffusers
# key format.
config_dict["format"] = ModelFormat.LyCORIS.value
if type == ModelType.CLIPVision.value:
# Prior to v6.9.0, some CLIP Vision models were associated with a specific base model architecture:
# - CLIP-ViT-bigG-14-laion2B-39B-b160k is the image encoder for SDXL IP Adapter and was associated with SDXL
# - CLIP-ViT-H-14-laion2B-s32B-b79K is the image encoder for SD1.5 IP Adapter and was associated with SD1.5
#
# While this made some sense at the time, it is more correct and flexible to treat CLIP Vision models
# as independent of any specific base model architecture.
config_dict["base"] = BaseModelType.Any.value
if type == ModelType.CLIPEmbed.value:
# Prior to v6.9.0, some CLIP Embed models did not have a variant set. The default was the L variant.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# L variant. Also, treat CLIP Embed models as independent of any specific base model architecture.
config_dict["base"] = BaseModelType.Any.value
config_dict["variant"] = config_dict.get("variant", ClipVariantType.L.value)
try:
migrated_config = AnyModelConfigValidator.validate_python(config_dict)
# This could be a ValidationError or any other error that occurs during validation. A failure to generate a
# union discriminator could raise a ValueError, for example. Who knows what else could fail - catch all.
except Exception as e:
self._logger.error("Failed to validate migrated config, attempting to save as unknown model: %s", e)
cloned_config_dict = deepcopy(config_dict)
cloned_config_dict.pop("base", None)
cloned_config_dict.pop("type", None)
cloned_config_dict.pop("format", None)
migrated_config = Unknown_Config(
**cloned_config_dict,
base=BaseModelType.Unknown,
type=ModelType.Unknown,
format=ModelFormat.Unknown,
)
return migrated_config
def build_migration_23(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 22 to version 23.
This migration updates model configurations to the latest config schemas for v6.9.0.
"""
return Migration(
id="migration_23",
depends_on="migration_22",
from_version=22,
to_version=23,
callback=Migration23Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,242 @@
import json
import sqlite3
from logging import Logger
from pathlib import Path
from typing import NamedTuple
from pydantic import ValidationError
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.configs.factory import AnyModelConfigValidator
class NormalizeResult(NamedTuple):
new_relative_path: str | None
rollback_ops: list[tuple[Path, Path]]
class Migration24Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
# Grab all model records
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
for model_id, config_json in rows:
try:
config = AnyModelConfigValidator.validate_json(config_json)
except ValidationError:
# This could happen if the config schema changed in a way that makes old configs invalid. Unlikely
# for users, more likely for devs testing out migration paths.
self._logger.warning("Skipping model %s: invalid config schema", model_id)
continue
except json.JSONDecodeError:
# This should never happen, as we use pydantic to serialize the config to JSON.
self._logger.warning("Skipping model %s: invalid config JSON", model_id)
continue
# We'll use a savepoint so we can roll back the database update if something goes wrong, and a simple
# rollback of file operations if needed.
cursor.execute("SAVEPOINT migrate_model")
try:
new_relative_path, rollback_ops = self._normalize_model_storage(
key=config.key,
path_value=config.path,
)
except Exception as err:
self._logger.error("Error normalizing model %s: %s", config.key, err)
cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
cursor.execute("RELEASE SAVEPOINT migrate_model")
continue
if new_relative_path is None:
cursor.execute("RELEASE SAVEPOINT migrate_model")
continue
config.path = new_relative_path
try:
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(config.model_dump_json(), model_id),
)
except Exception as err:
self._logger.error("Database update failed for model %s: %s", config.key, err)
cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
cursor.execute("RELEASE SAVEPOINT migrate_model")
self._rollback_file_ops(rollback_ops)
continue
cursor.execute("RELEASE SAVEPOINT migrate_model")
self._prune_empty_directories()
def _normalize_model_storage(self, key: str, path_value: str) -> NormalizeResult:
models_dir = self._models_dir
stored_path = Path(path_value)
relative_path: Path | None
if stored_path.is_absolute():
# If the stored path is absolute, we need to check if it's inside the models directory, which means it is
# an Invoke-managed model. If it's outside, it is user-managed we leave it alone.
try:
relative_path = stored_path.resolve().relative_to(models_dir)
except ValueError:
self._logger.info("Leaving user-managed model %s at %s", key, stored_path)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
else:
# Relative paths are always relative to the models directory and thus Invoke-managed.
relative_path = stored_path
# If the relative path is empty, assume something is wrong. Warn and skip.
if not relative_path.parts:
self._logger.warning("Skipping model %s: empty relative path", key)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
# Sanity check: the path is relative. It should be present in the models directory.
absolute_path = (models_dir / relative_path).resolve()
if not absolute_path.exists():
self._logger.warning(
"Skipping model %s: expected model files at %s but nothing was found",
key,
absolute_path,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
if relative_path.parts[0] == key:
# Already normalized. Still ensure the stored path is relative.
normalized_path = relative_path.as_posix()
# If the stored path is already the normalized path, no change is needed.
new_relative_path = normalized_path if stored_path.as_posix() != normalized_path else None
return NormalizeResult(new_relative_path=new_relative_path, rollback_ops=[])
# We'll store the file operations we perform so we can roll them back if needed.
rollback_ops: list[tuple[Path, Path]] = []
# Destination directory is models_dir/<key> - a flat directory structure.
destination_dir = models_dir / key
try:
if absolute_path.is_file():
destination_dir.mkdir(parents=True, exist_ok=True)
dest_file = destination_dir / absolute_path.name
# This really shouldn't happen.
if dest_file.exists():
self._logger.warning(
"Destination for model %s already exists at %s; skipping move",
key,
dest_file,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
self._logger.info("Moving model file %s -> %s", absolute_path, dest_file)
# `Path.rename()` effectively moves the file or directory.
absolute_path.rename(dest_file)
rollback_ops.append((dest_file, absolute_path))
return NormalizeResult(
new_relative_path=(Path(key) / dest_file.name).as_posix(),
rollback_ops=rollback_ops,
)
if absolute_path.is_dir():
dest_path = destination_dir
# This really shouldn't happen.
if dest_path.exists():
self._logger.warning(
"Destination directory %s already exists for model %s; skipping",
dest_path,
key,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
self._logger.info("Moving model directory %s -> %s", absolute_path, dest_path)
# `Path.rename()` effectively moves the file or directory.
absolute_path.rename(dest_path)
rollback_ops.append((dest_path, absolute_path))
return NormalizeResult(
new_relative_path=Path(key).as_posix(),
rollback_ops=rollback_ops,
)
# Maybe a broken symlink or something else weird?
self._logger.warning("Skipping model %s: path %s is neither a file nor directory", key, absolute_path)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
except Exception:
self._rollback_file_ops(rollback_ops)
raise
def _rollback_file_ops(self, rollback_ops: list[tuple[Path, Path]]) -> None:
# This is a super-simple rollback that just reverses the move operations we performed.
for source, destination in reversed(rollback_ops):
try:
if source.exists():
source.rename(destination)
except Exception as err:
self._logger.error("Failed to rollback move %s -> %s: %s", source, destination, err)
def _prune_empty_directories(self) -> None:
# These directories are system directories we want to keep even if empty. Technically, the app should not
# have any problems if these are removed, creating them as needed, but it's cleaner to just leave them alone.
keep_names = {"model_images", ".download_cache"}
keep_dirs = {self._models_dir / name for name in keep_names}
removed_dirs: set[Path] = set()
# Walk the models directory tree from the bottom up, removing empty directories. We sort by path length
# descending to ensure we visit children before parents.
for directory in sorted(self._models_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if not directory.is_dir():
continue
if directory == self._models_dir:
continue
if any(directory == keep or keep in directory.parents for keep in keep_dirs):
continue
try:
next(directory.iterdir())
except StopIteration:
try:
directory.rmdir()
removed_dirs.add(directory)
self._logger.debug("Removed empty directory %s", directory)
except OSError:
# Directory not empty (or some other error) - bail out.
self._logger.warning("Failed to prune directory %s - not empty?", directory)
continue
except OSError:
continue
self._logger.info("Pruned %d empty directories under %s", len(removed_dirs), self._models_dir)
def build_migration_24(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 23 to version 24.
This migration normalizes on-disk model storage so that each model lives within
a directory named by its key inside the Invoke-managed models directory, and
updates database records to reference the new relative paths.
This migration behaves a bit differently than others. Because it involves FS operations, if we rolled the
DB back on any failure, we could leave the FS out of sync with the DB. Instead, we use savepoints
to roll back individual model updates on failure, and we roll back any FS operations we performed
for that model.
If a model cannot be migrated for any reason (invalid config, missing files, FS errors, DB errors), we log a
warning and skip it, leaving it in its original state and location. The model will still work, but it will be in
the "wrong" location on disk.
"""
return Migration(
id="migration_24",
depends_on="migration_23",
from_version=23,
to_version=24,
callback=Migration24Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,63 @@
import json
import sqlite3
from logging import Logger
from typing import Any
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.taxonomy import ModelType, Qwen3VariantType
class Migration25Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_count = 0
for model_id, config_json in rows:
try:
config_dict: dict[str, Any] = json.loads(config_json)
if config_dict.get("type") != ModelType.Qwen3Encoder.value:
continue
if "variant" in config_dict:
continue
config_dict["variant"] = Qwen3VariantType.Qwen3_4B.value
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(json.dumps(config_dict), model_id),
)
migrated_count += 1
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
if migrated_count > 0:
self._logger.info(f"Migration complete: {migrated_count} Qwen3 encoder configs updated with variant field")
else:
self._logger.info("Migration complete: no Qwen3 encoder configs needed migration")
def build_migration_25(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 24 to version 25.
This migration adds the variant field to existing Qwen3 encoder models.
Models installed before the variant field was added will default to Qwen3_4B (for Z-Image compatibility).
"""
return Migration(
id="migration_25",
depends_on="migration_24",
from_version=24,
to_version=25,
callback=Migration25Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,117 @@
import json
import sqlite3
from logging import Logger
from pathlib import Path
from typing import Any
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, ZImageVariantType
class Migration26Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def _detect_variant_from_scheduler(self, model_path: Path) -> ZImageVariantType:
"""Detect Z-Image variant from scheduler config for Diffusers models.
Z-Image variants are distinguished by the scheduler shift value:
- Turbo (distilled): shift = 3.0
- Base (undistilled): shift = 6.0
"""
scheduler_config_path = model_path / "scheduler" / "scheduler_config.json"
if not scheduler_config_path.exists():
return ZImageVariantType.Turbo
try:
with open(scheduler_config_path, "r", encoding="utf-8") as f:
scheduler_config = json.load(f)
shift = scheduler_config.get("shift", 3.0)
# ZBase (undistilled) uses shift = 6.0, Turbo uses shift = 3.0
if shift >= 5.0:
return ZImageVariantType.ZBase
else:
return ZImageVariantType.Turbo
except (json.JSONDecodeError, OSError) as e:
self._logger.warning(f"Could not read scheduler config: {e}, defaulting to Turbo")
return ZImageVariantType.Turbo
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_turbo = 0
migrated_base = 0
for model_id, config_json in rows:
try:
config_dict: dict[str, Any] = json.loads(config_json)
# Only migrate Z-Image main models
if config_dict.get("base") != BaseModelType.ZImage.value:
continue
if config_dict.get("type") != ModelType.Main.value:
continue
# Skip if variant already set
if "variant" in config_dict:
continue
# Determine variant based on format
model_format = config_dict.get("format")
model_path = config_dict.get("path")
if model_format == ModelFormat.Diffusers.value and model_path:
# For Diffusers models, detect from scheduler config
variant = self._detect_variant_from_scheduler(Path(model_path))
else:
# For Checkpoint/GGUF, default to Turbo (Base only available as Diffusers)
variant = ZImageVariantType.Turbo
config_dict["variant"] = variant.value
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(json.dumps(config_dict), model_id),
)
if variant == ZImageVariantType.ZBase:
migrated_base += 1
else:
migrated_turbo += 1
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
total = migrated_turbo + migrated_base
if total > 0:
self._logger.info(
f"Migration complete: {total} Z-Image model configs updated "
f"({migrated_turbo} Turbo, {migrated_base} Base)"
)
else:
self._logger.info("Migration complete: no Z-Image model configs needed migration")
def build_migration_26(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 25 to version 26.
This migration adds the variant field to existing Z-Image main models.
Models installed before the variant field was added will default to Turbo
(the only variant available before Z-Image Base support was added).
"""
return Migration(
id="migration_26",
depends_on="migration_25",
from_version=25,
to_version=26,
callback=Migration26Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,368 @@
"""Migration 27: Add multi-user support, per-user client state, and app settings.
This migration adds the database schema for multi-user support, including:
- users table for user accounts
- user_sessions table for session management
- user_invitations table for invitation system
- shared_boards table for board sharing
- Adding user_id columns to existing tables for data ownership
- Restructuring client_state table to support per-user storage
- app_settings table for storing JWT secret and other app-level settings
"""
import json
import secrets
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration27Callback:
"""Migration to add multi-user support, per-user client state, and app settings."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_users_table(cursor)
self._create_user_sessions_table(cursor)
self._create_user_invitations_table(cursor)
self._create_shared_boards_table(cursor)
self._update_boards_table(cursor)
self._update_images_table(cursor)
self._update_workflows_table(cursor)
self._update_session_queue_table(cursor)
self._update_style_presets_table(cursor)
self._create_system_user(cursor)
self._update_client_state_table(cursor)
self._create_app_settings_table(cursor)
self._generate_jwt_secret(cursor)
def _create_users_table(self, cursor: sqlite3.Cursor) -> None:
"""Create users table."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
user_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_login_at DATETIME
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS tg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN
UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE user_id = old.user_id;
END;
""")
def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None:
"""Create user_sessions table for session management."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_sessions (
session_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);")
def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None:
"""Create user_invitations table for invitation system."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_invitations (
invitation_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
invited_by TEXT NOT NULL,
invitation_code TEXT NOT NULL UNIQUE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);")
def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Create shared_boards table for board sharing."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS shared_boards (
board_id TEXT NOT NULL,
user_id TEXT NOT NULL,
can_edit BOOLEAN NOT NULL DEFAULT FALSE,
shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (board_id, user_id),
FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);")
def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to boards table."""
# Check if boards table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
if cursor.fetchone() is None:
return
# Check if user_id column exists
cursor.execute("PRAGMA table_info(boards);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);")
def _update_images_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id column to images table."""
# Check if images table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(images);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);")
def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to workflows table."""
# Check if workflows table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(workflows);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);")
def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id column to session_queue table."""
# Check if session_queue table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);")
def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to style_presets table."""
# Check if style_presets table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(style_presets);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);")
def _create_system_user(self, cursor: sqlite3.Cursor) -> None:
"""Create system user for backward compatibility.
The system user is NOT an admin - it's just used to own existing data
from before multi-user support was added. Real admin users should be
created through the /auth/setup endpoint.
"""
cursor.execute("""
INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active)
VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE);
""")
def _update_client_state_table(self, cursor: sqlite3.Cursor) -> None:
"""Restructure client_state table to support per-user storage."""
# Check if client_state table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='client_state';")
if cursor.fetchone() is None:
# Table doesn't exist, create it with the new schema
cursor.execute(
"""
CREATE TABLE client_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
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 user_id = OLD.user_id AND key = OLD.key;
END;
"""
)
return
# Table exists with old schema - migrate it
# Get existing data if the data column is present (it may be absent if an older
# version of migration 21 was deployed without the column)
cursor.execute("PRAGMA table_info(client_state);")
columns = [row[1] for row in cursor.fetchall()]
existing_data = {}
if "data" in columns:
cursor.execute("SELECT data FROM client_state WHERE id = 1;")
row = cursor.fetchone()
if row is not None:
try:
existing_data = json.loads(row[0])
except (json.JSONDecodeError, TypeError):
# If data is corrupt, just start fresh
pass
# Drop the old table
cursor.execute("DROP TABLE IF EXISTS client_state;")
# Create new table with per-user schema
cursor.execute(
"""
CREATE TABLE client_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
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 user_id = OLD.user_id AND key = OLD.key;
END;
"""
)
# Migrate existing data to 'system' user
for key, value in existing_data.items():
cursor.execute(
"""
INSERT INTO client_state (user_id, key, value)
VALUES ('system', ?, ?);
""",
(key, value),
)
def _create_app_settings_table(self, cursor: sqlite3.Cursor) -> None:
"""Create app_settings table for storing application-level configuration."""
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
cursor.execute(
"""
CREATE TRIGGER IF NOT EXISTS tg_app_settings_updated_at
AFTER UPDATE ON app_settings
FOR EACH ROW
BEGIN
UPDATE app_settings SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE key = OLD.key;
END;
"""
)
def _generate_jwt_secret(self, cursor: sqlite3.Cursor) -> None:
"""Generate and store a cryptographically secure JWT secret key.
The secret is a 64-character hexadecimal string (256 bits of entropy),
which is suitable for HS256 JWT signing.
"""
# Check if JWT secret already exists
cursor.execute("SELECT value FROM app_settings WHERE key = 'jwt_secret';")
existing_secret = cursor.fetchone()
if existing_secret is None:
# Generate a new cryptographically secure secret (256 bits)
jwt_secret = secrets.token_hex(32) # 32 bytes = 256 bits = 64 hex characters
# Store in database
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES ('jwt_secret', ?);",
(jwt_secret,),
)
def build_migration_27() -> Migration:
"""Builds the migration object for migrating from version 26 to version 27.
This migration adds multi-user support, per-user client state, and app settings
(including a JWT secret) to the database schema.
"""
return Migration(
id="migration_27",
depends_on="migration_26",
from_version=26,
to_version=27,
callback=Migration27Callback(),
)
@@ -0,0 +1,50 @@
"""Migration 28: Add per-user workflow isolation columns to workflow_library.
This migration adds the database columns required for multiuser workflow isolation
to the workflow_library table:
- user_id: the owner of the workflow (defaults to 'system' for existing workflows)
- is_public: whether the workflow is shared with all users
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration28Callback:
"""Migration to add user_id and is_public to the workflow_library table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_workflow_library_table(cursor)
def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to workflow_library table."""
cursor.execute("PRAGMA table_info(workflow_library);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE workflow_library ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
cursor.execute(
"UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';"
) # one-time fix for legacy workflows
def build_migration_28() -> Migration:
"""Builds the migration object for migrating from version 27 to version 28.
This migration adds per-user workflow isolation to the workflow_library table:
- user_id column: identifies the owner of each workflow
- is_public column: controls whether a workflow is shared with all users
"""
return Migration(
id="migration_28",
depends_on="migration_27",
from_version=27,
to_version=28,
callback=Migration28Callback(),
)
@@ -0,0 +1,55 @@
"""Migration 29: Add board_visibility column to boards table.
This migration adds a board_visibility column to the boards table to support
three visibility levels:
- 'private': only the board owner (and admins) can view/modify
- 'shared': all users can view, but only the owner (and admins) can modify
- 'public': all users can view; only the owner (and admins) can modify the
board structure (rename/archive/delete)
Existing boards with is_public = 1 are migrated to 'public'.
All other existing boards default to 'private'.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration29Callback:
"""Migration to add board_visibility column to the boards table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_boards_table(cursor)
def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Add board_visibility column to boards table."""
# Check if boards table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(boards);")
columns = [row[1] for row in cursor.fetchall()]
if "board_visibility" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);")
# Migrate existing is_public = 1 boards to 'public'
if "is_public" in columns:
cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;")
def build_migration_29() -> Migration:
"""Builds the migration object for migrating from version 28 to version 29.
This migration adds the board_visibility column to the boards table,
supporting 'private', 'shared', and 'public' visibility levels.
"""
return Migration(
id="migration_29",
depends_on="migration_28",
from_version=28,
to_version=29,
callback=Migration29Callback(),
)
@@ -0,0 +1,72 @@
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration3Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_model_manager_metadata(cursor)
self._recreate_model_config(cursor)
def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `model_manager_metadata` table."""
cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;")
def _recreate_model_config(self, cursor: sqlite3.Cursor) -> None:
"""
Drops the `model_config` table, recreating it.
In 3.4.0, this table used explicit columns but was changed to use json_extract 3.5.0.
Because this table is not used in production, we are able to simply drop it and recreate it.
"""
cursor.execute("DROP TABLE IF EXISTS model_config;")
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_config (
id TEXT NOT NULL PRIMARY KEY,
-- The next 3 fields are enums in python, unrestricted string here
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
original_hash TEXT, -- could be null
-- Serialized JSON representation of the whole config object,
-- which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
"""
)
def build_migration_3(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""
Build the migration from database version 2 to 3.
This migration does the following:
- Drops the `model_config` table, recreating it
- Migrates data from `models.yaml` into the `model_config` table
"""
migration_3 = Migration(
id="migration_3",
depends_on="migration_2",
from_version=2,
to_version=3,
callback=Migration3Callback(app_config=app_config, logger=logger),
)
return migration_3
@@ -0,0 +1,35 @@
"""Migration 30: Add per-item queue status sequencing.
This migration adds a `status_sequence` column to `session_queue` so queue item
status updates can be ordered across asynchronous event and snapshot channels.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration30Callback:
"""Add a per-queue-item status sequence for cross-channel ordering."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "status_sequence" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN status_sequence INTEGER DEFAULT 0;")
cursor.execute("UPDATE session_queue SET status_sequence = 0 WHERE status_sequence IS NULL;")
def build_migration_30() -> Migration:
return Migration(
id="migration_30",
depends_on="migration_29",
from_version=29,
to_version=30,
callback=Migration30Callback(),
)
@@ -0,0 +1,43 @@
"""Migration 31: Add image_subfolder column to images table.
This migration adds an image_subfolder column to the images table to support
configurable image subfolder strategies (flat, date, type, hash).
Existing images get an empty string (flat/root directory).
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration31Callback:
"""Migration to add image_subfolder column to images table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_image_subfolder_column(cursor)
def _add_image_subfolder_column(self, cursor: sqlite3.Cursor) -> None:
"""Add image_subfolder column to images table."""
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(images);")
columns = [row[1] for row in cursor.fetchall()]
if "image_subfolder" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN image_subfolder TEXT NOT NULL DEFAULT '';")
def build_migration_31() -> Migration:
"""Builds the migration object for migrating from version 30 to version 31.
This migration adds an image_subfolder column to the images table.
"""
return Migration(
id="migration_31",
depends_on="migration_30",
from_version=30,
to_version=31,
callback=Migration31Callback(),
)
@@ -0,0 +1,93 @@
"""Migration 32: Repair model_relationships foreign keys.
Migration 22 rebuilt the `models` table by renaming it to `models_old`, creating a
fresh `models` table, copying the data over, and dropping `models_old`. Because
modern SQLite (with `legacy_alter_table` off) rewrites foreign-key references in
*other* tables when a table is renamed, the foreign keys in `model_relationships`
were silently repointed at `models_old` -- which was then dropped.
This left the related-models links referencing a table that no longer exists,
breaking `ON DELETE CASCADE` and foreign-key integrity for related models.
This migration rebuilds `model_relationships` so its foreign keys reference
`models(id)` again, preserving existing links and dropping any orphaned rows whose
model keys no longer exist (those would violate the restored foreign keys).
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration32Callback:
"""Migration to repair the broken foreign keys on the model_relationships table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._repair_model_relationships_fks(cursor)
def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships';")
row = cursor.fetchone()
if row is None:
# Table does not exist (fresh db will create it correctly), nothing to repair.
return
existing_sql: str = row[0]
if "models_old" not in existing_sql:
# Foreign keys already point at the correct table, nothing to repair.
return
# Rebuild the table with the correct foreign keys referencing models(id).
cursor.execute("ALTER TABLE model_relationships RENAME TO model_relationships_old;")
cursor.execute(
"""
-- many-to-many relationship table for models
CREATE TABLE model_relationships (
-- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
model_key_1 TEXT NOT NULL,
model_key_2 TEXT NOT NULL,
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (model_key_1, model_key_2),
-- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
);
"""
)
# Copy over the existing links, dropping any orphaned rows whose model keys no
# longer exist -- these would violate the restored foreign keys.
cursor.execute(
"""
INSERT INTO model_relationships (model_key_1, model_key_2, created_at)
SELECT model_key_1, model_key_2, created_at
FROM model_relationships_old
WHERE model_key_1 IN (SELECT id FROM models)
AND model_key_2 IN (SELECT id FROM models);
"""
)
# Drop the old table first so its index name is freed before we recreate it.
cursor.execute("DROP TABLE model_relationships_old;")
cursor.execute(
"""
-- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
ON model_relationships(model_key_2);
"""
)
def build_migration_32() -> Migration:
"""Builds the migration object for migrating from version 31 to version 32.
This migration repairs the foreign keys on the model_relationships table, which were
broken by migration 22 rebuilding the models table.
"""
return Migration(
id="migration_32",
depends_on="migration_31",
from_version=31,
to_version=32,
callback=Migration32Callback(),
)
@@ -0,0 +1,72 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration33Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS image_subfolder_move_jobs (
id INTEGER PRIMARY KEY,
state TEXT NOT NULL CHECK (
state IN ('planned', 'moving', 'moved', 'committed', 'error')
),
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
error_message TEXT
);
"""
)
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS image_subfolder_move_items (
job_id INTEGER NOT NULL REFERENCES image_subfolder_move_jobs(id),
image_name TEXT NOT NULL REFERENCES images(image_name),
old_subfolder TEXT NOT NULL,
new_subfolder TEXT NOT NULL,
is_intermediate BOOLEAN NOT NULL DEFAULT FALSE,
old_path TEXT,
new_path TEXT,
old_thumbnail_path TEXT,
new_thumbnail_path TEXT,
state TEXT NOT NULL CHECK (
state IN ('planned', 'moved', 'committed', 'error')
),
error_message TEXT,
PRIMARY KEY (job_id, image_name)
);
"""
)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_job_state
ON image_subfolder_move_items(job_id, state);
"""
)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_image_name
ON image_subfolder_move_items(image_name);
"""
)
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_image_subfolder_move_jobs_updated_at
AFTER UPDATE
ON image_subfolder_move_jobs FOR EACH ROW
BEGIN
UPDATE image_subfolder_move_jobs
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_33() -> Migration:
return Migration(
from_version=32,
to_version=33,
callback=Migration33Callback(),
)
@@ -0,0 +1,85 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration4Callback:
"""Callback to do step 4 of migration."""
def __call__(self, cursor: sqlite3.Cursor) -> None: # noqa D102
self._create_model_metadata(cursor)
self._create_model_tags(cursor)
self._create_tags(cursor)
self._create_triggers(cursor)
def _create_model_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Create the table used to store model metadata downloaded from remote sources."""
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_metadata (
id TEXT NOT NULL PRIMARY KEY,
name TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.name')) VIRTUAL NOT NULL,
author TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.author')) VIRTUAL NOT NULL,
-- Serialized JSON representation of the whole metadata object,
-- which will contain additional fields from subclasses
metadata TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY(id) REFERENCES model_config(id) ON DELETE CASCADE
);
"""
)
def _create_model_tags(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_tags (
model_id TEXT NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY(model_id) REFERENCES model_config(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE,
UNIQUE(model_id,tag_id)
);
"""
)
def _create_tags(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS tags (
tag_id INTEGER NOT NULL PRIMARY KEY,
tag_text TEXT NOT NULL UNIQUE
);
"""
)
def _create_triggers(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS model_metadata_updated_at
AFTER UPDATE
ON model_metadata FOR EACH ROW
BEGIN
UPDATE model_metadata SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_4() -> Migration:
"""
Build the migration from database version 3 to 4.
Adds the tables needed to store model metadata and tags.
"""
migration_4 = Migration(
id="migration_4",
depends_on="migration_3",
from_version=3,
to_version=4,
callback=Migration4Callback(),
)
return migration_4
@@ -0,0 +1,36 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration5Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_graph_executions(cursor)
def _drop_graph_executions(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `graph_executions` table."""
cursor.execute(
"""--sql
DROP TABLE IF EXISTS graph_executions;
"""
)
def build_migration_5() -> Migration:
"""
Build the migration from database version 4 to 5.
Introduced in v3.6.3, this migration:
- Drops the `graph_executions` table. We are able to do this because we are moving the graph storage
to be purely in-memory.
"""
migration_5 = Migration(
id="migration_5",
depends_on="migration_4",
from_version=4,
to_version=5,
callback=Migration5Callback(),
)
return migration_5
@@ -0,0 +1,64 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration6Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._recreate_model_triggers(cursor)
self._delete_ip_adapters(cursor)
def _recreate_model_triggers(self, cursor: sqlite3.Cursor) -> None:
"""
Adds the timestamp trigger to the model_config table.
This trigger was inadvertently dropped in earlier migration scripts.
"""
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS model_config_updated_at
AFTER UPDATE
ON model_config FOR EACH ROW
BEGIN
UPDATE model_config SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def _delete_ip_adapters(self, cursor: sqlite3.Cursor) -> None:
"""
Delete all the IP adapters.
The model manager will automatically find and re-add them after the migration
is done. This allows the manager to add the correct image encoder to their
configuration records.
"""
cursor.execute(
"""--sql
DELETE FROM model_config
WHERE type='ip_adapter';
"""
)
def build_migration_6() -> Migration:
"""
Build the migration from database version 5 to 6.
This migration does the following:
- Adds the model_config_updated_at trigger if it does not exist
- Delete all ip_adapter models so that the model prober can find and
update with the correct image processor model.
"""
migration_6 = Migration(
id="migration_6",
depends_on="migration_5",
from_version=5,
to_version=6,
callback=Migration6Callback(),
)
return migration_6
@@ -0,0 +1,90 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration7Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_models_table(cursor)
self._drop_old_models_tables(cursor)
def _drop_old_models_tables(self, cursor: sqlite3.Cursor) -> None:
"""Drops the old model_records, model_metadata, model_tags and tags tables."""
tables = ["model_config", "model_metadata", "model_tags", "tags"]
for table in tables:
cursor.execute(f"DROP TABLE IF EXISTS {table};")
def _create_models_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the v4.0.0 models table."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS models (
id TEXT NOT NULL PRIMARY KEY,
hash TEXT GENERATED ALWAYS as (json_extract(config, '$.hash')) VIRTUAL NOT NULL,
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(config, '$.description')) VIRTUAL,
source TEXT GENERATED ALWAYS as (json_extract(config, '$.source')) VIRTUAL NOT NULL,
source_type TEXT GENERATED ALWAYS as (json_extract(config, '$.source_type')) VIRTUAL NOT NULL,
source_api_response TEXT GENERATED ALWAYS as (json_extract(config, '$.source_api_response')) VIRTUAL,
trigger_phrases TEXT GENERATED ALWAYS as (json_extract(config, '$.trigger_phrases')) VIRTUAL,
-- Serialized JSON representation of the whole config object, which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
"""
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS models_updated_at
AFTER UPDATE
ON models FOR EACH ROW
BEGIN
UPDATE models SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS base_index ON models(base);",
"CREATE INDEX IF NOT EXISTS type_index ON models(type);",
"CREATE INDEX IF NOT EXISTS name_index ON models(name);",
"CREATE UNIQUE INDEX IF NOT EXISTS path_index ON models(path);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def build_migration_7() -> Migration:
"""
Build the migration from database version 6 to 7.
This migration does the following:
- Adds the new models table
- Drops the old model_records, model_metadata, model_tags and tags tables.
- TODO(MM2): Migrates model names and descriptions from `models.yaml` to the new table (?).
"""
migration_7 = Migration(
id="migration_7",
depends_on="migration_6",
from_version=6,
to_version=7,
callback=Migration7Callback(),
)
return migration_7
@@ -0,0 +1,93 @@
import sqlite3
from pathlib import Path
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration8Callback:
def __init__(self, app_config: InvokeAIAppConfig) -> None:
self._app_config = app_config
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_model_config_table(cursor)
self._migrate_abs_models_to_rel(cursor)
def _drop_model_config_table(self, cursor: sqlite3.Cursor) -> None:
"""Drops the old model_config table. This was missed in a previous migration."""
cursor.execute("DROP TABLE IF EXISTS model_config;")
def _migrate_abs_models_to_rel(self, cursor: sqlite3.Cursor) -> None:
"""Check all model paths & legacy config paths to determine if they are inside Invoke-managed directories. If
they are, update the paths to be relative to the managed directories.
This migration is a no-op for normal users (their paths will already be relative), but is necessary for users
who have been testing the RCs with their live databases. The paths were made absolute in the initial RC, but this
change was reverted. To smooth over the revert for our tests, we can migrate the paths back to relative.
"""
models_path = self._app_config.models_path
legacy_conf_path = self._app_config.legacy_conf_path
legacy_conf_dir = self._app_config.legacy_conf_dir
stmt = """---sql
SELECT
id,
path,
json_extract(config, '$.config_path') as config_path
FROM models;
"""
all_models = cursor.execute(stmt).fetchall()
for model_id, model_path, model_config_path in all_models:
# If the model path is inside the models directory, update it to be relative to the models directory.
if Path(model_path).is_relative_to(models_path):
new_path = Path(model_path).relative_to(models_path)
cursor.execute(
"""--sql
UPDATE models
SET config = json_set(config, '$.path', ?)
WHERE id = ?;
""",
(str(new_path), model_id),
)
# If the model has a legacy config path and it is inside the legacy conf directory, update it to be
# relative to the legacy conf directory. This also fixes up cases in which the config path was
# incorrectly relativized to the root directory. It will now be relativized to the legacy conf directory.
if model_config_path:
if Path(model_config_path).is_relative_to(legacy_conf_path):
new_config_path = Path(model_config_path).relative_to(legacy_conf_path)
elif Path(model_config_path).is_relative_to(legacy_conf_dir):
new_config_path = Path(*Path(model_config_path).parts[1:])
else:
new_config_path = None
if new_config_path:
cursor.execute(
"""--sql
UPDATE models
SET config = json_set(config, '$.config_path', ?)
WHERE id = ?;
""",
(str(new_config_path), model_id),
)
def build_migration_8(app_config: InvokeAIAppConfig) -> Migration:
"""
Build the migration from database version 7 to 8.
This migration does the following:
- Removes the `model_config` table.
- Migrates absolute model & legacy config paths to be relative to the models directory.
"""
migration_8 = Migration(
id="migration_8",
depends_on="migration_7",
from_version=7,
to_version=8,
callback=Migration8Callback(app_config),
)
return migration_8
@@ -0,0 +1,31 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration9Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._empty_session_queue(cursor)
def _empty_session_queue(self, cursor: sqlite3.Cursor) -> None:
"""Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas."""
cursor.execute("DELETE FROM session_queue;")
def build_migration_9() -> Migration:
"""
Build the migration from database version 8 to 9.
This migration does the following:
- Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas.
"""
migration_9 = Migration(
id="migration_9",
depends_on="migration_8",
from_version=8,
to_version=9,
callback=Migration9Callback(),
)
return migration_9
@@ -0,0 +1,284 @@
import sqlite3
from typing import Optional, Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator
@runtime_checkable
class MigrateCallback(Protocol):
"""
A callback that performs a migration.
Migrate callbacks are provided an open cursor to the database. They should not commit their
transaction; this is handled by the migrator.
If the callback needs to access additional dependencies, will be provided to the callback at runtime.
See :class:`Migration` for an example.
"""
def __call__(self, cursor: sqlite3.Cursor) -> None: ...
class MigrationError(RuntimeError):
"""Raised when a migration fails."""
class MigrationVersionError(ValueError):
"""Raised when a migration version is invalid."""
class Migration(BaseModel):
"""
Represents a migration for a SQLite database.
:param from_version: The legacy database version on which this migration may be run
:param to_version: The legacy database version that results from this migration
:param id: The stable migration ID. Legacy migrations default to ``migration_{to_version}``.
:param depends_on: The stable ID of the migration that must run first.
:param migrate_callback: The callback to run to perform the migration
Migrations are executed according to their stable ID dependencies. Existing legacy migrations also keep
``from_version`` and ``to_version`` so older numeric migration state can be mapped to applied migration IDs.
New graph-only migrations may omit legacy versions, but must provide an explicit ``id``.
Migration callbacks will be provided an open cursor to the database. They should not commit their
transaction; this is handled by the migrator.
It is suggested to use a class to define the migration callback and a builder function to create
the :class:`Migration`. This allows the callback to be provided with additional dependencies and
keeps things tidy, as all migration logic is self-contained.
Example:
```py
# Define the migration callback class
class Migration1Callback:
# This migration needs a logger, so we define a class that accepts a logger in its constructor.
def __init__(self, image_files: ImageFileStorageBase) -> None:
self._image_files = ImageFileStorageBase
# This dunder method allows the instance of the class to be called like a function.
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_with_banana_column(cursor)
self._do_something_with_images(cursor)
def _add_with_banana_column(self, cursor: sqlite3.Cursor) -> None:
\"""Adds the with_banana column to the sushi table.\"""
# Execute SQL using the cursor, taking care to *not commit* a transaction
cursor.execute('ALTER TABLE sushi ADD COLUMN with_banana BOOLEAN DEFAULT TRUE;')
def _do_something_with_images(self, cursor: sqlite3.Cursor) -> None:
\"""Does something with the image files service.\"""
self._image_files.get(...)
# Define the migration builder function. This function creates an instance of the migration callback
# class and returns a Migration.
def build_migration_1(image_files: ImageFileStorageBase) -> Migration:
\"""Builds the migration from database version 0 to 1.
Requires the image files service to...
\"""
migration_1 = Migration(
from_version=0,
to_version=1,
migrate_callback=Migration1Callback(image_files=image_files),
)
return migration_1
# Register the migration after all dependencies have been initialized
db = SqliteDatabase(db_path, logger)
migrator = SqliteMigrator(db)
migrator.register_migration(build_migration_1(image_files))
migrator.run_migrations()
```
"""
from_version: Optional[int] = Field(
default=None, ge=0, strict=True, description="The database version on which this migration may be run"
)
to_version: Optional[int] = Field(
default=None, ge=1, strict=True, description="The database version that results from this migration"
)
id: Optional[str] = Field(default=None, description="Stable migration ID")
depends_on: Optional[str] = Field(default=None, description="Stable ID of the migration dependency")
callback: MigrateCallback = Field(description="The callback to run to perform the migration")
@model_validator(mode="after")
def validate_versions_and_ids(self) -> "Migration":
"""Validates legacy versions and derives stable IDs for legacy migrations."""
has_from_version = self.from_version is not None
has_to_version = self.to_version is not None
if has_from_version != has_to_version:
raise MigrationVersionError("from_version and to_version must both be provided for legacy migrations")
if self.from_version is not None and self.to_version is not None and self.to_version != self.from_version + 1:
raise MigrationVersionError("to_version must be one greater than from_version")
if self.id is None and self.to_version is not None:
self.id = f"migration_{self.to_version}"
if self.id is None:
raise MigrationVersionError("id is required for graph-only migrations")
if self.depends_on is None and self.from_version is not None and self.from_version > 0:
self.depends_on = f"migration_{self.from_version}"
if self.depends_on == self.id:
raise MigrationVersionError("migration cannot depend on itself")
return self
def __hash__(self) -> int:
# Callables are not hashable, so we need to implement our own __hash__ function to use this class in a set.
if self.from_version is not None and self.to_version is not None:
return hash((self.from_version, self.to_version))
return hash(self.id)
@property
def sort_key(self) -> tuple[int, int, str]:
"""Deterministic sort key for runnable migrations."""
if self.to_version is None:
return (1, 0, self.id or "")
return (0, self.to_version, self.id or "")
model_config = ConfigDict(arbitrary_types_allowed=True)
class MigrationSet:
"""
A set of Migrations. Performs validation during migration registration and provides utility methods.
Migrations should be registered with `register()`. Once all are registered, `validate_dependency_graph()`
should be called to ensure that dependencies are complete and acyclic. `validate_migration_chain()` is retained for
legacy chain validation tests and compatibility checks.
"""
def __init__(self) -> None:
self._migrations: set[Migration] = set()
def register(self, migration: Migration) -> None:
"""Registers a migration."""
migration_from_already_registered = migration.from_version is not None and any(
m.from_version == migration.from_version for m in self._migrations if m.from_version is not None
)
migration_to_already_registered = migration.to_version is not None and any(
m.to_version == migration.to_version for m in self._migrations if m.to_version is not None
)
if migration_from_already_registered or migration_to_already_registered:
raise MigrationVersionError("Migration with from_version or to_version already registered")
migration_id_already_registered = any(m.id == migration.id for m in self._migrations)
if migration_id_already_registered:
raise MigrationVersionError("Migration with id already registered")
self._migrations.add(migration)
def get(self, from_version: int) -> Optional[Migration]:
"""Gets the migration that may be run on the given database version."""
# register() ensures that there is only one migration with a given from_version, so this is safe.
return next((m for m in self._migrations if m.from_version == from_version), None)
def validate_migration_chain(self) -> None:
"""
Validates that the migrations form a single chain of migrations from version 0 to the latest version,
Raises a MigrationError if there is a problem.
"""
if self.count == 0:
return
if self.latest_version == 0:
return
next_migration = self.get(from_version=0)
if next_migration is None:
raise MigrationError("Migration chain is fragmented")
touched_count = 1
while next_migration is not None:
next_migration = self.get(next_migration.to_version)
if next_migration is not None:
touched_count += 1
if touched_count != self.count:
raise MigrationError("Migration chain is fragmented")
def validate_dependency_graph(self) -> None:
"""Validates migration ID dependencies."""
migration_ids = {migration.id for migration in self._migrations}
migrations_by_id = self.migrations_by_id
for migration in self._migrations:
if migration.depends_on is not None and migration.depends_on not in migration_ids:
raise MigrationError(
f"Migration '{migration.id}' depends on unknown migration '{migration.depends_on}'"
)
if migration.to_version is not None and migration.depends_on is not None:
dependency = migrations_by_id[migration.depends_on]
if dependency.to_version is None:
raise MigrationError(
f"Legacy migration '{migration.id}' cannot depend on graph-only migration '{dependency.id}'"
)
visiting: set[str] = set()
visited: set[str] = set()
def visit(migration: Migration) -> None:
migration_id = migration.id
if migration_id is None:
raise MigrationError("Migration is missing id")
if migration_id in visited:
return
if migration_id in visiting:
raise MigrationError("Migration dependency graph contains a cycle")
visiting.add(migration_id)
if migration.depends_on is not None:
visit(migrations_by_id[migration.depends_on])
visiting.remove(migration_id)
visited.add(migration_id)
for migration in self._migrations:
visit(migration)
def get_migration_plan(self, applied_migration_ids: set[str]) -> list[Migration]:
"""Gets a deterministic migration plan from the set of applied migration IDs."""
self.validate_dependency_graph()
known_migration_ids = set(self.migrations_by_id)
unknown_applied_ids = applied_migration_ids - known_migration_ids
if unknown_applied_ids:
unknown_ids = ", ".join(sorted(unknown_applied_ids))
raise MigrationError(f"Database contains unknown applied migration IDs: {unknown_ids}")
plan: list[Migration] = []
planned_or_applied_ids = set(applied_migration_ids)
remaining = {
migration.id: migration for migration in self._migrations if migration.id not in applied_migration_ids
}
while remaining:
runnable = sorted(
(
migration
for migration in remaining.values()
if migration.depends_on is None or migration.depends_on in planned_or_applied_ids
),
key=lambda migration: migration.sort_key,
)
if not runnable:
raise MigrationError("Migration dependency graph cannot be resolved")
migration = runnable[0]
plan.append(migration)
planned_or_applied_ids.add(migration.id or "")
del remaining[migration.id]
return plan
@property
def count(self) -> int:
"""The count of registered migrations."""
return len(self._migrations)
@property
def latest_version(self) -> int:
"""Gets latest to_version among registered migrations. Returns 0 if there are no migrations registered."""
if self.count == 0:
return 0
legacy_migrations = [migration for migration in self._migrations if migration.to_version is not None]
if len(legacy_migrations) == 0:
return 0
latest_version = sorted(legacy_migrations, key=lambda m: m.to_version or 0)[-1].to_version
return latest_version or 0
@property
def migrations(self) -> tuple[Migration, ...]:
return tuple(sorted(self._migrations, key=lambda migration: migration.sort_key))
@property
def migrations_by_id(self) -> dict[str, Migration]:
return {migration.id or "": migration for migration in self._migrations}
@@ -0,0 +1,302 @@
import sqlite3
from contextlib import closing
from datetime import datetime
from pathlib import Path
from typing import Optional
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration, MigrationError, MigrationSet
class SqliteMigrator:
"""
Manages migrations for a SQLite database.
:param db: The instance of :class:`SqliteDatabase` to migrate.
Migrations should be registered with :meth:`register_migration`, either directly or via the migration loader.
They are planned by stable migration ID dependencies and recorded in the ``applied_migrations`` table.
Legacy numeric versions are still written for migrations that define ``to_version``.
Each migration is run in a transaction. If a migration fails, the transaction is rolled back.
Example Usage:
```py
db = SqliteDatabase(db_path="my_db.db", logger=logger)
migrator = SqliteMigrator(db=db)
migrator.register_migration(build_migration_1())
migrator.register_migration(build_migration_2())
migrator.run_migrations()
```
"""
backup_path: Optional[Path] = None
def __init__(self, db: SqliteDatabase) -> None:
self._db = db
self._logger = db._logger
self._migration_set = MigrationSet()
self._backup_path: Optional[Path] = None
def register_migration(self, migration: Migration) -> None:
"""Registers a migration."""
self._migration_set.register(migration)
self._logger.debug(f"Registered migration {migration.from_version} -> {migration.to_version}")
def run_migrations(self) -> bool:
"""Migrates the database to the latest version."""
# This throws if there is a problem.
self._migration_set.validate_dependency_graph()
cursor = self._db._conn.cursor()
self._validate_existing_applied_migrations(cursor=cursor)
self._create_migrations_table(cursor=cursor)
self._validate_existing_legacy_migrations(cursor=cursor)
if self._needs_applied_migrations_bootstrap(cursor=cursor):
self._backup_db()
self._create_applied_migrations_table(cursor=cursor)
self._validate_existing_applied_legacy_migrations(cursor=cursor)
self._bootstrap_applied_migrations_from_legacy_versions(cursor=cursor)
if self._migration_set.count == 0:
self._logger.debug("No migrations registered")
return False
applied_migration_ids = self._get_applied_migration_ids(cursor=cursor)
migration_plan = self._migration_set.get_migration_plan(applied_migration_ids=applied_migration_ids)
if len(migration_plan) == 0:
self._logger.debug("Database is up to date, no migrations to run")
return False
self._logger.info("Database update needed")
self._backup_db()
for migration in migration_plan:
self._run_migration(migration)
self._logger.info("Database updated successfully")
return True
def _backup_db(self) -> None:
"""Makes a backup of the db if it is a file db and a backup has not already been made."""
if self._backup_path is not None:
return
if self._db._db_path is not None:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
self._backup_path = self._db._db_path.parent / f"{self._db._db_path.stem}_backup_{timestamp}.db"
self._logger.info(f"Backing up database to {str(self._backup_path)}")
with closing(sqlite3.connect(self._backup_path)) as backup_conn:
self._db._conn.backup(backup_conn)
else:
self._logger.info("Using in-memory database, no backup needed")
def _run_migration(self, migration: Migration) -> None:
"""Runs a single migration."""
try:
# Using sqlite3.Connection as a context manager commits a the transaction on exit, or rolls it back if an
# exception is raised.
with self._db._conn as conn:
cursor = conn.cursor()
self._create_applied_migrations_table(cursor)
if migration.from_version is not None and self._get_current_version(cursor) != migration.from_version:
raise MigrationError(
f"Database is at version {self._get_current_version(cursor)}, expected {migration.from_version}"
)
self._logger.debug(f"Running migration '{migration.id}'")
# Run the actual migration
migration.callback(cursor)
if migration.to_version is not None:
cursor.execute("INSERT INTO migrations (version) VALUES (?);", (migration.to_version,))
cursor.execute(
"INSERT INTO applied_migrations (migration_id, legacy_version) VALUES (?, ?);",
(migration.id, migration.to_version),
)
self._logger.debug(f"Successfully ran migration '{migration.id}'")
# We want to catch *any* error, mirroring the behaviour of the sqlite3 module.
except Exception as e:
# The connection context manager has already rolled back the migration, so we don't need to do anything.
msg = f"Error running migration '{migration.id}': {e}"
self._logger.error(msg)
raise MigrationError(msg) from e
def _create_migrations_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the migrations table for the database, if one does not already exist."""
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
if cursor.fetchone() is not None:
return
cursor.execute(
"""--sql
CREATE TABLE migrations (
version INTEGER PRIMARY KEY,
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
cursor.execute("INSERT INTO migrations (version) VALUES (0);")
cursor.connection.commit()
self._logger.debug("Created migrations table")
except sqlite3.Error as e:
msg = f"Problem creating migrations table: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _create_applied_migrations_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the applied migrations table for stable migration IDs."""
try:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS applied_migrations (
migration_id TEXT PRIMARY KEY,
legacy_version INTEGER UNIQUE,
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
except sqlite3.Error as e:
msg = f"Problem creating applied_migrations table: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _bootstrap_applied_migrations_from_legacy_versions(self, cursor: sqlite3.Cursor) -> None:
"""Backfills applied migration IDs from legacy numeric migration rows."""
try:
cursor.execute("SELECT version FROM migrations WHERE version > 0 ORDER BY version;")
legacy_versions = [row[0] for row in cursor.fetchall()]
registered_migration_ids = self._migration_set.migrations_by_id
for legacy_version in legacy_versions:
migration_id = f"migration_{legacy_version}"
if migration_id not in registered_migration_ids:
cursor.connection.rollback()
raise MigrationError(f"Database contains unknown legacy migration version: {legacy_version}")
cursor.execute(
"SELECT legacy_version FROM applied_migrations WHERE migration_id = ?;",
(migration_id,),
)
migration_row = cursor.fetchone()
if migration_row is not None and migration_row[0] != legacy_version:
cursor.connection.rollback()
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is recorded with legacy version {migration_row[0]}, "
f"expected {legacy_version}"
)
cursor.execute(
"SELECT migration_id FROM applied_migrations WHERE legacy_version = ?;",
(legacy_version,),
)
legacy_row = cursor.fetchone()
if legacy_row is not None and legacy_row[0] != migration_id:
cursor.connection.rollback()
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"legacy version {legacy_version} is recorded for {legacy_row[0]}, expected {migration_id}"
)
cursor.execute(
"INSERT OR IGNORE INTO applied_migrations (migration_id, legacy_version) VALUES (?, ?);",
(migration_id, legacy_version),
)
cursor.connection.commit()
except sqlite3.Error as e:
msg = f"Problem bootstrapping applied migrations: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _validate_existing_applied_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates existing applied migration IDs before creating or mutating migrator metadata."""
applied_migration_ids = self._get_applied_migration_ids(cursor=cursor)
if len(applied_migration_ids) == 0:
return
known_migration_ids = set(self._migration_set.migrations_by_id)
unknown_applied_ids = applied_migration_ids - known_migration_ids
if unknown_applied_ids:
unknown_ids = ", ".join(sorted(unknown_applied_ids))
raise MigrationError(f"Database contains unknown applied migration IDs: {unknown_ids}")
def _validate_existing_legacy_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates existing legacy migration versions before creating applied migration metadata."""
try:
cursor.execute("SELECT version FROM migrations WHERE version > 0 ORDER BY version;")
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return
raise
registered_migration_ids = self._migration_set.migrations_by_id
for row in cursor.fetchall():
legacy_version = row[0]
migration_id = f"migration_{legacy_version}"
if migration_id not in registered_migration_ids:
raise MigrationError(f"Database contains unknown legacy migration version: {legacy_version}")
def _validate_existing_applied_legacy_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates applied IDs for legacy migrations against legacy numeric rows."""
registered_migrations = self._migration_set.migrations_by_id
cursor.execute("SELECT migration_id, legacy_version FROM applied_migrations;")
applied_rows = cursor.fetchall()
cursor.execute("SELECT version FROM migrations WHERE version > 0;")
legacy_versions = {row[0] for row in cursor.fetchall()}
for row in applied_rows:
migration_id = row[0]
legacy_version = row[1]
migration = registered_migrations[migration_id]
if migration.to_version is None:
continue
if legacy_version != migration.to_version:
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is recorded with legacy version {legacy_version}, "
f"expected {migration.to_version}"
)
if legacy_version not in legacy_versions:
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is applied, but legacy version {legacy_version} is missing"
)
def _needs_applied_migrations_bootstrap(self, cursor: sqlite3.Cursor) -> bool:
"""Checks whether legacy numeric rows need to be written to applied_migrations."""
cursor.execute("SELECT version FROM migrations WHERE version > 0;")
legacy_versions = {row[0] for row in cursor.fetchall()}
if len(legacy_versions) == 0:
return False
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='applied_migrations';")
if cursor.fetchone() is None:
return True
cursor.execute("SELECT legacy_version FROM applied_migrations WHERE legacy_version IS NOT NULL;")
applied_legacy_versions = {row[0] for row in cursor.fetchall()}
return not legacy_versions.issubset(applied_legacy_versions)
@classmethod
def _get_applied_migration_ids(cls, cursor: sqlite3.Cursor) -> set[str]:
"""Gets applied stable migration IDs."""
try:
cursor.execute("SELECT migration_id FROM applied_migrations;")
return {row[0] for row in cursor.fetchall()}
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return set()
raise
@classmethod
def _get_current_version(cls, cursor: sqlite3.Cursor) -> int:
"""Gets the current version of the database, or 0 if the migrations table does not exist."""
try:
cursor.execute("SELECT MAX(version) FROM migrations;")
version: int = cursor.fetchone()[0]
if version is None:
return 0
return version
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return 0
raise