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
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:
@@ -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(),
|
||||
)
|
||||
+53
@@ -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(),
|
||||
)
|
||||
+60
@@ -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
|
||||
Reference in New Issue
Block a user