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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
# InvokeAI Graph - Design Overview
High-level design for the graph module. Focuses on responsibilities, data flow, and how traversal works.
## 1) Purpose
Provide a typed, acyclic workflow model (**Graph**) plus a runtime scheduler (**GraphExecutionState**) that expands
iterator patterns, tracks readiness via indegree (the number of incoming edges to a node in the directed graph), and
executes nodes in class-grouped batches. In normal execution, runtime expansion happens in a separate execution graph
instead of mutating the source graph.
## 2) Major Data Types
### EdgeConnection
- Fields: `node_id: str`, `field: str`.
- Hashable; printed as `node.field` for readable diagnostics.
### Edge
- Fields: `source: EdgeConnection`, `destination: EdgeConnection`.
- One directed connection from a specific output port to a specific input port.
### AnyInvocation / AnyInvocationOutput
- Pydantic wrappers that carry concrete invocation models and outputs.
- No registry logic in this file; they are permissive containers for heterogeneous nodes.
### IterateInvocation / CollectInvocation
- Control nodes used by validation and execution:
- **IterateInvocation**: input `collection`, outputs include `item` (and index/total).
- **CollectInvocation**: many `item` inputs aggregated to one `collection` output.
## 3) Graph (author-time model)
A container for declared nodes and edges. Does **not** perform iteration expansion.
### 3.1 Data
- `nodes: dict[str, AnyInvocation]` - key must equal `node.id`.
- `edges: list[Edge]` - zero or more.
- Utility: `_get_input_edges(node_id, field?)`, `_get_output_edges(node_id, field?)` These scan `self.edges` (no
adjacency indices in the current code).
### 3.2 Validation (`validate_self`)
Runs a sequence of checks:
1. **Node ID uniqueness** No duplicate IDs; map key equals `node.id`.
1. **Endpoint existence** Source and destination node IDs must exist.
1. **Port existence** Input ports must exist on the node class; output ports on the node's output model.
1. **DAG constraint** Build a *flat* `DiGraph` (no runtime expansion) and assert acyclicity.
1. **Type compatibility** `get_output_field_type` vs `get_input_field_type` and `are_connection_types_compatible`.
Special case:
- `call_saved_workflow` currently accepts dynamic destination handles of the form
`saved_workflow_input::{childNodeId}::{childFieldName}` as part of its temporary call-boundary contract.
- Those handles are allowed through graph validation even though they are not static Python model fields on the
invocation class.
- Runtime later validates them against the selected child workflow's exposed callable interface before applying
values to the child graph.
- The editor preserves dynamic caller values only while the exposed field type remains compatible; type drift at the
same child node/field path resets to the selected workflow's current initial value.
- Saved-workflow picker search is server-backed so large workflow libraries do not require scrolling every page
before selecting a workflow by name.
1. **Iterator / collector structure** Enforce special rules:
- Iterator's input must be `collection`; its outgoing edges use `item`.
- Collector accepts many `item` inputs; outputs a single `collection`.
- Edge fan-in to a non-collector input is rejected.
### 3.3 Edge admission (`_validate_edge`)
Checks a single prospective edge before insertion:
- Endpoints/ports exist.
- Destination port is not already occupied unless it's a collector `item`.
- Adding the edge to the flat DAG must keep it acyclic.
- Iterator/collector constraints re-checked when the edge creates relevant patterns.
### 3.4 Topology utilities
- `nx_graph()` - DiGraph of declared nodes and edges.
- `nx_graph_flat()` - "flattened" DAG (still author-time; no runtime copies). Used in validation and in `_prepare()`
during execution planning.
### 3.5 Mutation helpers
- `add_node`, `update_node` (preserve edges, rewrite endpoints if id changes), `delete_node`.
- `add_edge`, `delete_edge` (with validation).
## 4) GraphExecutionState (runtime)
Holds the state for a single run. Keeps the source graph intact and materializes a separate execution graph.
`GraphExecutionState` is still the public runtime entry point, but most execution behavior is now delegated to a small
set of internal helper classes.
The source graph is treated as stable during normal execution, but the runtime object still exposes guarded graph
mutation helpers. Those helpers reject changes once the affected nodes have already been prepared or executed.
### 4.1 Data
- `graph: Graph` - source graph for the run; treated as stable during normal execution.
- `execution_graph: Graph` - materialized runtime nodes/edges. This is mutable runtime state, not an immutable audit
log. Lazy `If` pruning may remove unselected input edges during execution, so persisted failed/completed session
snapshots can contain a structurally pruned execution graph. Retry paths rebuild from `graph`, not from a previously
persisted `execution_graph`.
- `executed: set[str]`, `executed_history: list[str]`.
- `results: dict[str, AnyInvocationOutput]`, `errors: dict[str, str]`.
- `prepared_source_mapping: dict[str, str]` - exec id -> source id.
- `source_prepared_mapping: dict[str, set[str]]` - source id -> exec ids.
- `indegree: dict[str, int]` - unmet inputs per exec node.
- Workflow-call runtime state:
- `workflow_call_stack` - active parent call frames.
- `workflow_call_history` - completed or failed workflow-call relationships observed by this execution state.
- `workflow_call_parent` - parent workflow-call relationship metadata when this execution state is a child session.
- `waiting_workflow_call` - the call frame currently suspending this execution state, if any.
- `waiting_workflow_call_execution` - the active parent/child workflow-call relationship record for the waiting call.
- `waiting_workflow_call_child_session` - attached child execution state for the waiting workflow call, if any.
- `max_workflow_call_depth` - runtime guardrail for nested or recursive workflow calls.
- Prepared exec metadata caches:
- source node id
- iteration path
- runtime state such as pending, ready, executed, or skipped
- **Ready queues grouped by class** (private attrs): `_ready_queues: dict[class_name, deque[str]]`,
`_active_class: Optional[str]`. Optional `ready_order: list[str]` to prioritize classes. Queues are rebuilt from
persisted execution state when a session is deserialized.
### 4.2 Core methods
- `next()` Returns the next ready exec node. If none are ready, it asks the materializer to expand more source nodes and
then retries. If the execution state is paused on a workflow call boundary, it returns `None` without scheduling more
work. Before returning a node, the runtime helper deep-copies inbound values into the node fields.
- `complete(node_id, output)` Records the result, marks the exec node executed, marks the source node executed once all
of its prepared exec copies are done, then decrements downstream indegrees and enqueues newly ready nodes.
Workflow-call note:
- `GraphExecutionState` can represent a paused parent execution plus an attached child execution state, but it does not
itself orchestrate child execution.
- In the current implementation, `DefaultSessionRunner.run_node()` establishes the workflow call boundary and attaches
the child execution state, while `WorkflowCallCoordinator` handles call-specific setup and
`WorkflowCallQueueLifecycle` later resumes or fails the parent based on that child queue row's outcome.
- Child `SessionQueueItem` rows created by the coordinator now carry explicit relationship metadata such as
`workflow_call_id`, `parent_item_id`, `parent_session_id`, `root_item_id`, and `workflow_call_depth`, even though the
higher-level scheduler semantics are still evolving.
- The `session_queue` schema now has matching columns for those relationship fields, and parent queue items can enter a
`waiting` status while suspended on a child workflow execution.
- Queue lifecycle semantics are now partially defined for workflow-call chains:
- child success resumes the waiting parent
- multiple child queue rows may complete under one waiting parent when the called workflow contains direct batch
nodes; the parent resumes only after all expected child rows complete
- child failure fails the waiting parent and can cascade upward through ancestors
- failing child rows cancel their remaining workflow-call siblings before the parent is failed
- cancelation is chain-aware across parents and children, including nested descendants of batched siblings
- "all except current" queue actions preserve the active current item plus its workflow-call chain, while still
canceling or deleting unrelated waiting chains
- startup recovery cancels interrupted `in_progress` or `waiting` workflow-call chains, including pending descendants
- deleting a workflow-call queue row currently deletes the whole parent/child chain rather than leaving orphaned rows
behind
- retry is root-oriented and should not be exposed directly on child queue rows in the UI
- child queue-row creation is cleaned up on boundary-setup failure and child fan-out is bounded by remaining queue
capacity
- child workflows that mix supported batch nodes with unrelated generator nodes are rejected for now
- This is still an intermediate architecture step and should eventually be replaced by a more general parent/child
execution mechanism rather than workflow-call-specific queue lifecycle handling.
### 4.3 Runtime helper classes
`GraphExecutionState` now delegates most runtime behavior to internal helpers:
- `_PreparedExecRegistry` Owns the relationship between source graph nodes and prepared execution graph nodes, plus
cached metadata such as iteration path and runtime state.
- `_ExecutionMaterializer` Expands source graph nodes into concrete execution graph nodes when the scheduler runs out of
ready work. It owns iterator expansion, collector grouping, prepared-parent selection, and creation of execution-graph
edges. When matching prepared parents for a downstream exec node, skipped prepared exec nodes are ignored and cannot
be selected as live inputs.
- `_ExecutionScheduler` Owns indegree transitions, ready queues, class batching, and downstream release on completion.
- `_ExecutionRuntime` Owns iteration-path lookup, collect input ordering, and input hydration for prepared exec nodes.
- `_IfBranchScheduler` Applies lazy `If` semantics by deferring branch-local work until the condition is known, then
releasing the selected branch and skipping the unselected branch.
`GraphExecutionState.model_post_init()` rehydrates private runtime helpers and caches after normal construction or a
JSON/model round trip. Rehydration reconstructs prepared exec metadata, cached iteration paths, resolved `If` branch
state when the condition is already available, and ready queues from `execution_graph`, `indegree`, `executed`, and
`results`. This keeps persisted sessions resumable without persisting private helper objects.
### 4.4 Preparation (`_prepare()`)
- Build a flat DAG from the **source** graph.
- Choose the **next source node** in topological order that:
1. has not been prepared,
1. if it is an iterator, *its inputs are already executed*,
1. it has *no unexecuted iterator ancestors*.
- If the node is a **CollectInvocation**: group prepared parent exec nodes by iteration path and create one collector
exec node per group. A collector collapses the immediate iterator that feeds its `item` input, but preserves enclosing
iterator paths. This lets a shape such as `outer_iter -> inner_collection -> inner_iter -> collect -> consumer`
produce one collected result per outer iteration instead of mixing all inner items into one global collection.
Incoming `collection` inputs are treated as ancestor groups and are copied into each matching descendant item group.
- Otherwise: compute all combinations of prepared iterator ancestors. For each combination, choose the prepared parent
for each upstream by matching iterator ancestry, then create **one** exec node. If a node no longer has visible
iterator ancestors because the source path crosses a collector, prepared parent iteration paths are still used to
materialize one downstream exec node for each preserved collector path.
- For each new exec node:
- Deep-copy the source node; assign a fresh ID (and `index` for iterators).
- Cache the preserved iteration path when the materializer has one, such as for grouped collectors.
- Wire edges from chosen prepared parents.
- Set `indegree = number of unmet inputs` (i.e., parents not yet executed).
- Try to resolve any `If`-specific scheduling state.
- If the node is ready and not deferred by an unresolved `If`, enqueue it into its class queue.
### 4.5 Readiness and batching
- `_enqueue_if_ready(nid)` enqueues by class name only when `indegree == 0`, the node has not already executed, and the
node is not deferred by an unresolved `If`.
- `_get_next_node()` drains the `_active_class` queue; when empty, selects the next nonempty class queue (by
`ready_order` if set, else alphabetical), and continues. Within each class queue, ready exec nodes are ordered by
iteration path so expanded iterator work runs in a stable outer-to-inner order. Optional fairness knobs can limit
batch size per class; default is drain fully.
#### 4.5.1 Indegree (what it is and how it's used)
**Indegree** is the number of incoming edges to a node in the execution graph that are still unmet. In this engine:
- For every materialized exec node, `indegree[node]` equals the count of its prerequisite parents that have **not**
finished yet.
- A node is "ready" exactly when `indegree[node] == 0`; only then is it enqueued.
- When a node completes, the scheduler decrements `indegree[child]` for each outgoing edge. Any child that reaches 0 is
enqueued.
Example: edges `A->C`, `B->C`, `C->D`. Start: `A:0, B:0, C:2, D:1`. Run `A` -> `C:1`. Run `B` -> `C:0` -> enqueue `C`.
Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done.
### 4.6 Input hydration (`_prepare_inputs()`)
- For **CollectInvocation**: gather the materialized incoming `item` values into `collection`, sorting inputs by
iteration path so collected results are stable across expanded iterations. Incoming `collection` values are merged
first, then incoming `item` values are appended. By the time hydration runs, the materializer has already selected the
iteration group for this collector exec node, so hydration only sees inputs that belong to that group.
- For **IfInvocation**: hydrate only `condition` and the selected branch input. As a defensive guard against
inconsistent runtime or deserialized session state, the runtime raises if the selected input edge points at an exec
node with no stored runtime output. In normal scheduling this path should be unreachable.
- For all others: deep-copy each incoming edge's value into the destination field. This prevents cross-node mutation
through shared references.
### 4.7 Lazy `If` semantics
`IfInvocation` now acts as a lazy branch boundary rather than a simple value multiplexer.
- The `condition` input must resolve first.
- Nodes that are exclusive to the true or false branch can remain deferred even when their indegree is zero.
- Once the prepared `If` node resolves its condition:
- the selected branch is released
- the unselected branch is marked skipped
- unselected input edges on the prepared `If` exec node are pruned from the execution graph so they no longer
participate in downstream indegree accounting
- branch-exclusive ancestors of the unselected branch are never executed
- Skipped branch-local exec nodes may still be treated as executed for scheduling purposes, but they do not create
entries in `results`.
- Shared ancestors still execute if they are required by the selected branch or by any other live path in the graph.
This behavior is implemented in the runtime scheduler, not in the invocation body itself.
## 5) Traversal Summary
1. Author builds a valid **Graph**.
1. Create **GraphExecutionState** with that graph.
1. Loop:
- `node = state.next()` -> may trigger `_prepare()` expansion.
- Execute node externally -> `output`.
- `state.complete(node.id, output)` -> updates indegrees, `If` state, and ready queues.
1. Finish when `next()` returns `None` and the execution state is not paused waiting on a workflow call boundary.
In normal execution, all runtime expansion occurs in `execution_graph` with traceability back to source nodes.
## 6) Invariants
- Source **Graph** remains a DAG and type-consistent.
- `execution_graph` remains a DAG.
- Nodes are enqueued only when `indegree == 0` and they are not deferred by an unresolved `If`.
- `results` and `errors` are keyed by **exec node id**.
- Collectors aggregate `item` inputs and may also merge incoming `collection` inputs during runtime hydration.
Collectors nested under iterators preserve enclosing iteration paths, so downstream consumers materialize per enclosing
iteration instead of receiving a mixed collection from unrelated outer iterations.
- Branch-exclusive nodes behind an unselected `If` branch are skipped, not failed.
## 7) Extensibility
- **New node types**: implement as Pydantic models with typed fields and outputs. Register per your invocation system;
this file accepts them as `AnyInvocation`.
- **Scheduling policy**: adjust `ready_order` to batch by class; add a batch cap for fairness without changing
complexity.
- **Dynamic behaviors** (future): can be added in `GraphExecutionState` by creating exec nodes and edges at `complete()`
time, as long as the DAG invariant holds.
- **Workflow call boundaries**: `GraphExecutionState` can suspend a parent execution state on a workflow call, attach a
child execution state, and later resume the parent without mutating the source graph.
Current limitation:
- Child workflow executions are now represented as first-class queue items. Parent resume/failure is intentionally
handled by a dedicated workflow-call queue lifecycle component for this PR because no other feature currently needs a
generalized dependent-queue scheduler.
- Called workflows currently require exactly one valid `workflow_return` node to be callable at all.
- A single `workflow_return_value.value` may connect directly to `workflow_return.values`; multiple named return members
should be collected and then connected to `workflow_return.values`.
- Direct batch-special child workflows are now supported by expanding them into multiple child queue rows.
- Batch outputs may feed a named `workflow_return_value.value` directly. Parent resume aggregates named return maps as
`values: dict[str, list[Any]]`, and all rows in one batch call must return the same key set.
- Generator-backed batch child workflows are now supported when the batch node is fed directly by a supported integer,
float, string, or image generator.
- Connected batch child inputs produced by ordinary non-generator upstream nodes are still rejected before any child
queue row is created.
- Workflow library API responses now include compatibility metadata so the frontend can disable unsupported callees
before execution rather than failing only at runtime.
- Workflow library list compatibility uses structural generator-backed batch validation so list and picker rendering do
not enumerate every image in board-backed generators; workflow detail and runtime execution still resolve real
generator values.
- Batch-specific compatibility failures, including multiple connected inputs to one batch field, are reported as
`unsupported_batch_input` rather than generic unsupported-node failures.
- The workflow library list also surfaces that metadata as an informational unsupported state; workflows remain
viewable/editable even when they are not currently callable by `call_saved_workflow`.
- Single-user workflow CRUD socket events emit only to the admin room because every single-user socket already joins
that room, avoiding duplicate delivery through both `user:system` and `admin`.
## 8) Error Model (selected)
- `DuplicateNodeIdError`, `NodeAlreadyInGraphError`
- `NodeNotFoundError`, `NodeFieldNotFoundError`
- `InvalidEdgeError`, `CyclicalGraphError`
- `NodeInputError` (raised when preparing inputs for execution)
Messages favor short, precise diagnostics (node id, field, and failing condition).
## 9) Rationale
- **Two-graph approach** isolates authoring from execution expansion and keeps validation simple.
- **Indegree + queues** gives O(1) scheduling decisions with clear batching semantics.
- **Iterator/collector separation** keeps fan-out/fan-in explicit and testable.
- **Deep-copy hydration** avoids incidental aliasing bugs between nodes.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,808 @@
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional, Union
from PIL.Image import Image
from pydantic.networks import AnyHttpUrl
from torch import Tensor
from invokeai.app.invocations.constants import IMAGE_MODES
from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata
from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.model_records.model_records_base import UnknownModelException
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.util.step_callback import diffusion_step_callback
from invokeai.backend.model_manager.configs.base import Config_Base
from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.load.load_base import LoadedModel, LoadedModelWithoutConfig
from invokeai.backend.model_manager.taxonomy import AnyModel, BaseModelType, ModelFormat, ModelType, SubModelType
from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
if TYPE_CHECKING:
from invokeai.app.invocations.baseinvocation import BaseInvocation
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
"""
The InvocationContext provides access to various services and data about the current invocation.
We do not provide the invocation services directly, as their methods are both dangerous and
inconvenient to use.
For example:
- The `images` service allows nodes to delete or unsafely modify existing images.
- The `configuration` service allows nodes to change the app's config at runtime.
- The `events` service allows nodes to emit arbitrary events.
Wrapping these services provides a simpler and safer interface for nodes to use.
When a node executes, a fresh `InvocationContext` is built for it, ensuring nodes cannot interfere
with each other.
Many of the wrappers have the same signature as the methods they wrap. This allows us to write
user-facing docstrings and not need to go and update the internal services to match.
Note: The docstrings are in weird places, but that's where they must be to get IDEs to see them.
"""
@dataclass
class InvocationContextData:
queue_item: "SessionQueueItem"
"""The queue item that is being executed."""
invocation: "BaseInvocation"
"""The invocation that is being executed."""
source_invocation_id: str
"""The ID of the invocation from which the currently executing invocation was prepared."""
class InvocationContextInterface:
def __init__(self, services: InvocationServices, data: InvocationContextData) -> None:
self._services = services
self._data = data
class BoardsInterface(InvocationContextInterface):
def create(self, board_name: str) -> BoardDTO:
"""Creates a board for the current user.
Args:
board_name: The name of the board to create.
Returns:
The created board DTO.
"""
user_id = self._data.queue_item.user_id
return self._services.boards.create(board_name, user_id)
def get_dto(self, board_id: str) -> BoardDTO:
"""Gets a board DTO.
Args:
board_id: The ID of the board to get.
Returns:
The board DTO.
"""
return self._services.boards.get_dto(board_id)
def get_all(self) -> list[BoardDTO]:
"""Gets all boards accessible to the current user.
Returns:
A list of all boards accessible to the current user.
"""
user_id = self._data.queue_item.user_id
return self._services.boards.get_all(
user_id, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending
)
def add_image_to_board(self, board_id: str, image_name: str) -> None:
"""Adds an image to a board.
Args:
board_id: The ID of the board to add the image to.
image_name: The name of the image to add to the board.
"""
return self._services.board_images.add_image_to_board(board_id, image_name)
def get_all_image_names_for_board(self, board_id: str) -> list[str]:
"""Gets all image names for a board.
Args:
board_id: The ID of the board to get the image names for.
Returns:
A list of all image names for the board.
"""
return self._services.board_images.get_all_board_image_names_for_board(
board_id,
categories=None,
is_intermediate=None,
)
class LoggerInterface(InvocationContextInterface):
def debug(self, message: str) -> None:
"""Logs a debug message.
Args:
message: The message to log.
"""
self._services.logger.debug(message)
def info(self, message: str) -> None:
"""Logs an info message.
Args:
message: The message to log.
"""
self._services.logger.info(message)
def warning(self, message: str) -> None:
"""Logs a warning message.
Args:
message: The message to log.
"""
self._services.logger.warning(message)
def error(self, message: str) -> None:
"""Logs an error message.
Args:
message: The message to log.
"""
self._services.logger.error(message)
class ImagesInterface(InvocationContextInterface):
def __init__(self, services: InvocationServices, data: InvocationContextData, util: "UtilInterface") -> None:
super().__init__(services, data)
self._util = util
def save(
self,
image: Image,
board_id: Optional[str] = None,
image_category: ImageCategory = ImageCategory.GENERAL,
metadata: Optional[MetadataField] = None,
) -> ImageDTO:
"""Saves an image, returning its DTO.
If the current queue item has a workflow or metadata, it is automatically saved with the image.
Args:
image: The image to save, as a PIL image.
board_id: The board ID to add the image to, if it should be added. It the invocation \
inherits from `WithBoard`, that board will be used automatically. **Use this only if \
you want to override or provide a board manually!**
image_category: The category of the image. Only the GENERAL category is added \
to the gallery.
metadata: The metadata to save with the image, if it should have any. If the \
invocation inherits from `WithMetadata`, that metadata will be used automatically. \
**Use this only if you want to override or provide metadata manually!**
Returns:
The saved image DTO.
"""
self._util.signal_progress("Saving image")
# If `metadata` is provided directly, use that. Else, use the metadata provided by `WithMetadata`, falling back to None.
metadata_ = None
if metadata:
metadata_ = metadata.model_dump_json()
elif isinstance(self._data.invocation, WithMetadata) and self._data.invocation.metadata:
metadata_ = self._data.invocation.metadata.model_dump_json()
# If `board_id` is provided directly, use that. Else, use the board provided by `WithBoard`, falling back to None.
board_id_ = None
if board_id:
board_id_ = board_id
elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board:
board_id_ = self._data.invocation.board.board_id
workflow_ = None
if self._data.queue_item.workflow:
workflow_ = self._data.queue_item.workflow.model_dump_json()
graph_ = None
if self._data.queue_item.session.graph:
graph_ = self._data.queue_item.session.graph.model_dump_json()
return self._services.images.create(
image=image,
is_intermediate=self._data.invocation.is_intermediate,
image_category=image_category,
board_id=board_id_,
metadata=metadata_,
image_origin=ResourceOrigin.INTERNAL,
workflow=workflow_,
graph=graph_,
session_id=self._data.queue_item.session_id,
node_id=self._data.invocation.id,
user_id=self._data.queue_item.user_id,
)
def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image:
"""Gets an image as a PIL Image object. This method returns a copy of the image.
Args:
image_name: The name of the image to get.
mode: The color mode to convert the image to. If None, the original mode is used.
Returns:
The image as a PIL Image object.
"""
image = self._services.images.get_pil_image(image_name)
if mode and mode != image.mode:
try:
# convert makes a copy!
image = image.convert(mode)
except ValueError:
self._services.logger.warning(
f"Could not convert image from {image.mode} to {mode}. Using original mode instead."
)
else:
# copy the image to prevent the user from modifying the original
image = image.copy()
return image
def get_metadata(self, image_name: str) -> Optional[MetadataField]:
"""Gets an image's metadata, if it has any.
Args:
image_name: The name of the image to get the metadata for.
Returns:
The image's metadata, if it has any.
"""
return self._services.images.get_metadata(image_name)
def get_dto(self, image_name: str) -> ImageDTO:
"""Gets an image as an ImageDTO object.
Args:
image_name: The name of the image to get.
Returns:
The image as an ImageDTO object.
"""
return self._services.images.get_dto(image_name)
def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
"""Gets the internal path to an image or thumbnail.
Args:
image_name: The name of the image to get the path of.
thumbnail: Get the path of the thumbnail instead of the full image
Returns:
The local path of the image or thumbnail.
"""
return Path(self._services.images.get_path(image_name, thumbnail))
class TensorsInterface(InvocationContextInterface):
def save(self, tensor: Tensor) -> str:
"""Saves a tensor, returning its name.
Args:
tensor: The tensor to save.
Returns:
The name of the saved tensor.
"""
name = self._services.tensors.save(obj=tensor)
return name
def load(self, name: str) -> Tensor:
"""Loads a tensor by name. This method returns a copy of the tensor.
Args:
name: The name of the tensor to load.
Returns:
The tensor.
"""
return self._services.tensors.load(name).clone()
class ConditioningInterface(InvocationContextInterface):
def save(self, conditioning_data: ConditioningFieldData) -> str:
"""Saves a conditioning data object, returning its name.
Args:
conditioning_data: The conditioning data to save.
Returns:
The name of the saved conditioning data.
"""
name = self._services.conditioning.save(obj=conditioning_data)
return name
def load(self, name: str) -> ConditioningFieldData:
"""Loads conditioning data by name. This method returns a copy of the conditioning data.
Args:
name: The name of the conditioning data to load.
Returns:
The conditioning data.
"""
return deepcopy(self._services.conditioning.load(name))
class ModelsInterface(InvocationContextInterface):
"""Common API for loading, downloading and managing models."""
def __init__(self, services: InvocationServices, data: InvocationContextData, util: "UtilInterface") -> None:
super().__init__(services, data)
self._util = util
def exists(self, identifier: Union[str, "ModelIdentifierField"]) -> bool:
"""Check if a model exists.
Args:
identifier: The key or ModelField representing the model.
Returns:
True if the model exists, False if not.
"""
if isinstance(identifier, str):
return self._services.model_manager.store.exists(identifier)
else:
return self._services.model_manager.store.exists(identifier.key)
def load(
self, identifier: Union[str, "ModelIdentifierField"], submodel_type: Optional[SubModelType] = None
) -> LoadedModel:
"""Load a model.
Args:
identifier: The key or ModelField representing the model.
submodel_type: The submodel of the model to get.
Returns:
An object representing the loaded model.
"""
# The model manager emits events as it loads the model. It needs the context data to build
# the event payloads.
if isinstance(identifier, str):
model = self._services.model_manager.store.get_model(identifier)
else:
submodel_type = submodel_type or identifier.submodel_type
model = self._services.model_manager.store.get_model(identifier.key)
self._raise_if_external(model)
message = f"Loading model {model.name}"
if submodel_type:
message += f" ({submodel_type.value})"
self._util.signal_progress(message)
return self._services.model_manager.load.load_model(model, submodel_type)
def load_by_attrs(
self, name: str, base: BaseModelType, type: ModelType, submodel_type: Optional[SubModelType] = None
) -> LoadedModel:
"""Load a model by its attributes.
Args:
name: Name of the model.
base: The models' base type, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc.
type: Type of the model, e.g. `ModelType.Main`, `ModelType.Vae`, etc.
submodel_type: The type of submodel to load, e.g. `SubModelType.UNet`, `SubModelType.TextEncoder`, etc. Only main
models have submodels.
Returns:
An object representing the loaded model.
"""
configs = self._services.model_manager.store.search_by_attr(model_name=name, base_model=base, model_type=type)
if len(configs) == 0:
raise UnknownModelException(f"No model found with name {name}, base {base}, and type {type}")
if len(configs) > 1:
raise ValueError(f"More than one model found with name {name}, base {base}, and type {type}")
self._raise_if_external(configs[0])
message = f"Loading model {name}"
if submodel_type:
message += f" ({submodel_type.value})"
self._util.signal_progress(message)
return self._services.model_manager.load.load_model(configs[0], submodel_type)
@staticmethod
def _raise_if_external(model: AnyModelConfig) -> None:
if model.base == BaseModelType.External or model.format == ModelFormat.ExternalApi:
raise ValueError("External API models cannot be loaded from disk")
def get_config(self, identifier: Union[str, "ModelIdentifierField"]) -> AnyModelConfig:
"""Get a model's config.
Args:
identifier: The key or ModelField representing the model.
Returns:
The model's config.
"""
if isinstance(identifier, str):
return self._services.model_manager.store.get_model(identifier)
else:
return self._services.model_manager.store.get_model(identifier.key)
def search_by_path(self, path: Path) -> list[AnyModelConfig]:
"""Search for models by path.
Args:
path: The path to search for.
Returns:
A list of models that match the path.
"""
return self._services.model_manager.store.search_by_path(path)
def search_by_attrs(
self,
name: Optional[str] = None,
base: Optional[BaseModelType] = None,
type: Optional[ModelType] = None,
format: Optional[ModelFormat] = None,
) -> list[AnyModelConfig]:
"""Search for models by attributes.
Args:
name: The name to search for (exact match).
base: The base to search for, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc.
type: Type type of model to search for, e.g. `ModelType.Main`, `ModelType.Vae`, etc.
format: The format of model to search for, e.g. `ModelFormat.Checkpoint`, `ModelFormat.Diffusers`, etc.
Returns:
A list of models that match the attributes.
"""
return self._services.model_manager.store.search_by_attr(
model_name=name,
base_model=base,
model_type=type,
model_format=format,
)
def download_and_cache_model(
self,
source: str | AnyHttpUrl,
) -> Path:
"""
Download the model file located at source to the models cache and return its Path.
This can be used to single-file install models and other resources of arbitrary types
which should not get registered with the database. If the model is already
installed, the cached path will be returned. Otherwise it will be downloaded.
Args:
source: A URL that points to the model, or a huggingface repo_id.
Returns:
Path to the downloaded model
"""
self._util.signal_progress(f"Downloading model {source}")
return self._services.model_manager.install.download_and_cache_model(source=source)
def load_local_model(
self,
model_path: Path,
loader: Optional[Callable[[Path], AnyModel]] = None,
) -> LoadedModelWithoutConfig:
"""
Load the model file located at the indicated path
If a loader callable is provided, it will be invoked to load the model. Otherwise,
`safetensors.torch.load_file()` or `torch.load()` will be called to load the model.
Be aware that the LoadedModelWithoutConfig object has no `config` attribute
Args:
path: A model Path
loader: A Callable that expects a Path and returns a dict[str|int, Any]
Returns:
A LoadedModelWithoutConfig object.
"""
self._util.signal_progress(f"Loading model {model_path.name}")
return self._services.model_manager.load.load_model_from_path(model_path=model_path, loader=loader)
def load_remote_model(
self,
source: str | AnyHttpUrl,
loader: Optional[Callable[[Path], AnyModel]] = None,
) -> LoadedModelWithoutConfig:
"""
Download, cache, and load the model file located at the indicated URL or repo_id.
If the model is already downloaded, it will be loaded from the cache.
If the a loader callable is provided, it will be invoked to load the model. Otherwise,
`safetensors.torch.load_file()` or `torch.load()` will be called to load the model.
Be aware that the LoadedModelWithoutConfig object has no `config` attribute
Args:
source: A URL or huggingface repoid.
loader: A Callable that expects a Path and returns a dict[str|int, Any]
Returns:
A LoadedModelWithoutConfig object.
"""
model_path = self._services.model_manager.install.download_and_cache_model(source=str(source))
self._util.signal_progress(f"Loading model {source}")
return self._services.model_manager.load.load_model_from_path(model_path=model_path, loader=loader)
def get_absolute_path(self, config_or_path: AnyModelConfig | Path | str) -> Path:
"""Gets the absolute path for a given model config or path.
For example, if the model's path is `flux/main/FLUX Dev.safetensors`, and the models path is
`/home/username/InvokeAI/models`, this method will return
`/home/username/InvokeAI/models/flux/main/FLUX Dev.safetensors`.
Args:
config_or_path: The model config or path.
Returns:
The absolute path to the model.
"""
model_path = Path(config_or_path.path) if isinstance(config_or_path, Config_Base) else Path(config_or_path)
if model_path.is_absolute():
return model_path.resolve()
base_models_path = self._services.configuration.models_path
joined_path = base_models_path / model_path
resolved_path = joined_path.resolve()
return resolved_path
class ConfigInterface(InvocationContextInterface):
def get(self) -> InvokeAIAppConfig:
"""Gets the app's config.
Returns:
The app's config.
"""
return self._services.configuration
class UtilInterface(InvocationContextInterface):
def __init__(
self, services: InvocationServices, data: InvocationContextData, is_canceled: Callable[[], bool]
) -> None:
super().__init__(services, data)
self._is_canceled = is_canceled
def is_canceled(self) -> bool:
"""Checks if the current session has been canceled.
Returns:
True if the current session has been canceled, False if not.
"""
return self._is_canceled()
def sd_step_callback(self, intermediate_state: PipelineIntermediateState, base_model: BaseModelType) -> None:
"""
The step callback emits a progress event with the current step, the total number of
steps, a preview image, and some other internal metadata.
This should be called after each denoising step.
Args:
intermediate_state: The intermediate state of the diffusion pipeline.
base_model: The base model for the current denoising step.
"""
diffusion_step_callback(
signal_progress=self.signal_progress,
intermediate_state=intermediate_state,
base_model=base_model,
is_canceled=self.is_canceled,
)
def flux_step_callback(self, intermediate_state: PipelineIntermediateState) -> None:
"""
The step callback emits a progress event with the current step, the total number of
steps, a preview image, and some other internal metadata.
This should be called after each denoising step.
Args:
intermediate_state: The intermediate state of the diffusion pipeline.
"""
diffusion_step_callback(
signal_progress=self.signal_progress,
intermediate_state=intermediate_state,
base_model=BaseModelType.Flux,
is_canceled=self.is_canceled,
)
def flux2_step_callback(self, intermediate_state: PipelineIntermediateState) -> None:
"""
The step callback for FLUX.2 Klein models (32-channel VAE).
Args:
intermediate_state: The intermediate state of the diffusion pipeline.
"""
diffusion_step_callback(
signal_progress=self.signal_progress,
intermediate_state=intermediate_state,
base_model=BaseModelType.Flux2,
is_canceled=self.is_canceled,
)
def signal_progress(
self,
message: str,
percentage: float | None = None,
image: Image | None = None,
image_size: tuple[int, int] | None = None,
) -> None:
"""Signals the progress of some long-running invocation. The progress is displayed in the UI.
If a percentage is provided, the UI will display a progress bar and automatically append the percentage to the
message. You should not include the percentage in the message.
Example:
```py
total_steps = 10
for i in range(total_steps):
percentage = i / (total_steps - 1)
context.util.signal_progress("Doing something cool", percentage)
```
If an image is provided, the UI will display it. If your image should be displayed at a different size, provide
a tuple of `(width, height)` for the `image_size` parameter. The image will be displayed at the specified size
in the UI.
For example, SD denoising progress images are 1/8 the size of the original image, so you'd do this to ensure the
image is displayed at the correct size:
```py
# Calculate the output size of the image (8x the progress image's size)
width = progress_image.width * 8
height = progress_image.height * 8
# Signal the progress with the image and output size
signal_progress("Denoising", percentage, progress_image, (width, height))
```
If your progress image is very large, consider downscaling it to reduce the payload size and provide the original
size to the `image_size` parameter. The PIL `thumbnail` method is useful for this, as it maintains the aspect
ratio of the image:
```py
# `thumbnail` modifies the image in-place, so we need to first make a copy
thumbnail_image = progress_image.copy()
# Resize the image to a maximum of 256x256 pixels, maintaining the aspect ratio
thumbnail_image.thumbnail((256, 256))
# Signal the progress with the thumbnail, passing the original size
signal_progress("Denoising", percentage, thumbnail, progress_image.size)
```
Args:
message: A message describing the current status. Do not include the percentage in this message.
percentage: The current percentage completion for the process. Omit for indeterminate progress.
image: An optional image to display.
image_size: The optional size of the image to display. If omitted, the image will be displayed at its
original size.
"""
self._services.events.emit_invocation_progress(
queue_item=self._data.queue_item,
invocation=self._data.invocation,
message=message,
percentage=percentage,
image=ProgressImage.build(image, image_size) if image else None,
)
class InvocationContext:
"""Provides access to various services and data for the current invocation.
Attributes:
images (ImagesInterface): Methods to save, get and update images and their metadata.
tensors (TensorsInterface): Methods to save and get tensors, including image, noise, masks, and masked images.
conditioning (ConditioningInterface): Methods to save and get conditioning data.
models (ModelsInterface): Methods to check if a model exists, get a model, and get a model's info.
logger (LoggerInterface): The app logger.
config (ConfigInterface): The app config.
util (UtilInterface): Utility methods, including a method to check if an invocation was canceled and step callbacks.
boards (BoardsInterface): Methods to interact with boards.
"""
def __init__(
self,
images: ImagesInterface,
tensors: TensorsInterface,
conditioning: ConditioningInterface,
models: ModelsInterface,
logger: LoggerInterface,
config: ConfigInterface,
util: UtilInterface,
boards: BoardsInterface,
data: InvocationContextData,
services: InvocationServices,
) -> None:
self.images = images
"""Methods to save, get and update images and their metadata."""
self.tensors = tensors
"""Methods to save and get tensors, including image, noise, masks, and masked images."""
self.conditioning = conditioning
"""Methods to save and get conditioning data."""
self.models = models
"""Methods to check if a model exists, get a model, and get a model's info."""
self.logger = logger
"""The app logger."""
self.config = config
"""The app config."""
self.util = util
"""Utility methods, including a method to check if an invocation was canceled and step callbacks."""
self.boards = boards
"""Methods to interact with boards."""
self._data = data
"""An internal API providing access to data about the current queue item and invocation. You probably shouldn't use this. It may change without warning."""
self._services = services
"""An internal API providing access to all application services. You probably shouldn't use this. It may change without warning."""
def build_invocation_context(
services: InvocationServices,
data: InvocationContextData,
is_canceled: Callable[[], bool],
) -> InvocationContext:
"""Builds the invocation context for a specific invocation execution.
Args:
services: The invocation services to wrap.
data: The invocation context data.
Returns:
The invocation context.
"""
logger = LoggerInterface(services=services, data=data)
tensors = TensorsInterface(services=services, data=data)
config = ConfigInterface(services=services, data=data)
util = UtilInterface(services=services, data=data, is_canceled=is_canceled)
conditioning = ConditioningInterface(services=services, data=data)
models = ModelsInterface(services=services, data=data, util=util)
images = ImagesInterface(services=services, data=data, util=util)
boards = BoardsInterface(services=services, data=data)
ctx = InvocationContext(
images=images,
logger=logger,
config=config,
tensors=tensors,
models=models,
data=data,
util=util,
conditioning=conditioning,
services=services,
boards=boards,
)
return ctx
@@ -0,0 +1,41 @@
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
GenericBaseModel = TypeVar("GenericBaseModel", bound=BaseModel)
class CursorPaginatedResults(BaseModel, Generic[GenericBaseModel]):
"""
Cursor-paginated results
Generic must be a Pydantic model
"""
limit: int = Field(..., description="Limit of items to get")
has_more: bool = Field(..., description="Whether there are more items available")
items: list[GenericBaseModel] = Field(..., description="Items")
class OffsetPaginatedResults(BaseModel, Generic[GenericBaseModel]):
"""
Offset-paginated results
Generic must be a Pydantic model
"""
limit: int = Field(description="Limit of items to get")
offset: int = Field(description="Offset from which to retrieve items")
total: int = Field(description="Total number of items in result")
items: list[GenericBaseModel] = Field(description="Items")
class PaginatedResults(BaseModel, Generic[GenericBaseModel]):
"""
Paginated results
Generic must be a Pydantic model
"""
page: int = Field(description="Current Page")
pages: int = Field(description="Total number of pages")
per_page: int = Field(description="Number of items per page")
total: int = Field(description="Total number of items in result")
items: list[GenericBaseModel] = Field(description="Items")
@@ -0,0 +1,10 @@
from enum import Enum
from invokeai.app.util.metaenum import MetaEnum
sqlite_memory = ":memory:"
class SQLiteDirection(str, Enum, metaclass=MetaEnum):
Ascending = "ASC"
Descending = "DESC"
@@ -0,0 +1,93 @@
import sqlite3
import threading
from collections.abc import Generator
from contextlib import contextmanager
from logging import Logger
from pathlib import Path
from invokeai.app.services.shared.sqlite.sqlite_common import sqlite_memory
class SqliteDatabase:
"""
Manages a connection to an SQLite database.
:param db_path: Path to the database file. If None, an in-memory database is used.
:param logger: Logger to use for logging.
:param verbose: Whether to log SQL statements. Provides `logger.debug` as the SQLite trace callback.
This is a light wrapper around the `sqlite3` module, providing a few conveniences:
- The database file is written to disk if it does not exist.
- Foreign key constraints are enabled by default.
- The connection is configured to use the `sqlite3.Row` row factory.
In addition to the constructor args, the instance provides the following attributes and methods:
- `conn`: A `sqlite3.Connection` object. Note that the connection must never be closed if the database is in-memory.
- `lock`: A shared re-entrant lock, used to approximate thread safety.
- `clean()`: Runs the SQL `VACUUM;` command and reports on the freed space.
"""
def __init__(self, db_path: Path | None, logger: Logger, verbose: bool = False) -> None:
"""Initializes the database. This is used internally by the class constructor."""
self._logger = logger
self._db_path = db_path
self._verbose = verbose
self._lock = threading.RLock()
if not self._db_path:
logger.info("Initializing in-memory database")
else:
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._logger.info(f"Initializing database at {self._db_path}")
self._conn = sqlite3.connect(database=self._db_path or sqlite_memory, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
if self._verbose:
self._conn.set_trace_callback(self._logger.debug)
# Enable foreign key constraints
self._conn.execute("PRAGMA foreign_keys = ON;")
# Enable Write-Ahead Logging (WAL) mode for better concurrency
self._conn.execute("PRAGMA journal_mode = WAL;")
# Set a busy timeout to prevent database lockups during writes
self._conn.execute("PRAGMA busy_timeout = 5000;") # 5 seconds
def clean(self) -> None:
"""
Cleans the database by running the VACUUM command, reporting on the freed space.
"""
# No need to clean in-memory database
if not self._db_path:
return
try:
with self._conn as conn:
initial_db_size = Path(self._db_path).stat().st_size
conn.execute("VACUUM;")
conn.commit()
final_db_size = Path(self._db_path).stat().st_size
freed_space_in_mb = round((initial_db_size - final_db_size) / 1024 / 1024, 2)
if freed_space_in_mb > 0:
self._logger.info(f"Cleaned database (freed {freed_space_in_mb}MB)")
except Exception as e:
self._logger.error(f"Error cleaning database: {e}")
raise
@contextmanager
def transaction(self) -> Generator[sqlite3.Cursor, None, None]:
"""
Thread-safe context manager for DB work.
Acquires the RLock, yields a Cursor, then commits or rolls back.
"""
with self._lock:
cursor = self._conn.cursor()
try:
yield cursor
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
cursor.close()
@@ -0,0 +1,32 @@
from logging import Logger
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.sqlite_database import SqliteDatabase
from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileStorageBase) -> SqliteDatabase:
"""
Initializes the SQLite database.
:param config: The app config
:param logger: The logger
:param image_files: The image files service (used by migration 2)
This function:
- Instantiates a :class:`SqliteDatabase`
- Instantiates a :class:`SqliteMigrator` and registers all migrations
- Runs all migrations
"""
db_path = None if config.use_memory_db else config.db_path
db = SqliteDatabase(db_path=db_path, logger=logger, verbose=config.log_sql)
migrator = SqliteMigrator(db=db)
migration_context = MigrationBuildContext(app_config=config, logger=logger, image_files=image_files)
for migration in build_migrations(migration_context):
migrator.register_migration(migration)
migrator.run_migrations()
return db
@@ -0,0 +1,136 @@
import importlib
import inspect
import pkgutil
import re
from collections.abc import Callable
from dataclasses import dataclass
from logging import Logger
from types import ModuleType
from typing import Any
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
DEFAULT_MIGRATIONS_PACKAGE = "invokeai.app.services.shared.sqlite_migrator.migrations"
_MIGRATION_MODULE_RE = re.compile(r"^migration_(\d+)$")
_DATED_MIGRATION_MODULE_RE = re.compile(r"^migration_(\d{4}_\d{2}_\d{2}_[a-z0-9][a-z0-9_]*)$")
class MigrationLoaderError(RuntimeError):
"""Raised when migration discovery or construction fails."""
@dataclass(frozen=True)
class MigrationBuildContext:
app_config: InvokeAIAppConfig
logger: Logger
image_files: ImageFileStorageBase
def get_dependency(self, name: str) -> Any:
if name == "app_config":
return self.app_config
if name == "config":
return self.app_config
if name == "logger":
return self.logger
if name == "image_files":
return self.image_files
raise MigrationLoaderError(f"Migration builder requested unknown dependency '{name}'")
MigrationBuilder = Callable[..., Migration]
@dataclass(frozen=True)
class DiscoveredMigrationBuilder:
module_name: str
sort_key: tuple[int, int | str]
expected_migration_id: str
builder: MigrationBuilder
def discover_migration_builders(package_name: str = DEFAULT_MIGRATIONS_PACKAGE) -> list[DiscoveredMigrationBuilder]:
try:
package = importlib.import_module(package_name)
except Exception as e:
raise MigrationLoaderError(f"Unable to import migrations package '{package_name}': {e}") from e
package_path = getattr(package, "__path__", None)
if package_path is None:
raise MigrationLoaderError(f"Migrations package '{package_name}' is not a package")
discovered: list[DiscoveredMigrationBuilder] = []
for module_info in pkgutil.iter_modules(package_path):
module_short_name = module_info.name
if not module_short_name.startswith("migration_"):
continue
numeric_match = _MIGRATION_MODULE_RE.match(module_short_name)
dated_match = _DATED_MIGRATION_MODULE_RE.match(module_short_name)
if numeric_match is None and dated_match is None:
raise MigrationLoaderError(f"Malformed migration module name '{module_short_name}'")
if numeric_match is not None:
sort_key: tuple[int, int | str] = (0, int(numeric_match.group(1)))
expected_builder_name = f"build_migration_{numeric_match.group(1)}"
expected_migration_id = f"migration_{numeric_match.group(1)}"
else:
sort_key = (1, module_short_name)
expected_builder_name = "build_migration"
expected_migration_id = module_short_name.removeprefix("migration_")
full_module_name = f"{package_name}.{module_short_name}"
try:
module = importlib.import_module(full_module_name)
except Exception as e:
raise MigrationLoaderError(f"Unable to import migration module '{full_module_name}': {e}") from e
builder = _get_builder(module, expected_builder_name)
discovered.append(DiscoveredMigrationBuilder(full_module_name, sort_key, expected_migration_id, builder))
return sorted(discovered, key=lambda discovered_builder: discovered_builder.sort_key)
def build_migrations(context: MigrationBuildContext, package_name: str = DEFAULT_MIGRATIONS_PACKAGE) -> list[Migration]:
migrations: list[Migration] = []
for discovered_builder in discover_migration_builders(package_name):
kwargs = _get_builder_kwargs(discovered_builder.builder, context, discovered_builder.module_name)
migration = discovered_builder.builder(**kwargs)
if not isinstance(migration, Migration):
raise MigrationLoaderError(
f"Migration builder '{discovered_builder.builder.__name__}' in "
f"'{discovered_builder.module_name}' must return Migration"
)
if migration.id != discovered_builder.expected_migration_id:
raise MigrationLoaderError(
f"Migration builder '{discovered_builder.builder.__name__}' in "
f"'{discovered_builder.module_name}' must return migration id "
f"'{discovered_builder.expected_migration_id}', got '{migration.id}'"
)
migrations.append(migration)
return migrations
def _get_builder(module: ModuleType, builder_name: str) -> MigrationBuilder:
builder = getattr(module, builder_name, None)
if builder is None:
raise MigrationLoaderError(f"Migration module '{module.__name__}' must define '{builder_name}'")
if not callable(builder):
raise MigrationLoaderError(f"Migration builder '{module.__name__}.{builder_name}' is not callable")
return builder
def _get_builder_kwargs(builder: MigrationBuilder, context: MigrationBuildContext, module_name: str) -> dict[str, Any]:
signature = inspect.signature(builder)
kwargs: dict[str, Any] = {}
for parameter in signature.parameters.values():
if parameter.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
raise MigrationLoaderError(
f"Migration builder '{module_name}.{builder.__name__}' must not use *args or **kwargs"
)
if parameter.kind == inspect.Parameter.POSITIONAL_ONLY:
raise MigrationLoaderError(
f"Migration builder '{module_name}.{builder.__name__}' must not use positional-only parameters"
)
kwargs[parameter.name] = context.get_dependency(parameter.name)
return kwargs
@@ -0,0 +1,374 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration1Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
"""Migration callback for database version 1."""
self._create_board_images(cursor)
self._create_boards(cursor)
self._create_images(cursor)
self._create_model_config(cursor)
self._create_session_queue(cursor)
self._create_workflow_images(cursor)
self._create_workflows(cursor)
def _create_board_images(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `board_images` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS board_images (
board_id TEXT NOT NULL,
image_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
-- enforce one-to-many relationship between boards and images using PK
-- (we can extend this to many-to-many later)
PRIMARY KEY (image_name),
FOREIGN KEY (board_id) REFERENCES boards (board_id) ON DELETE CASCADE,
FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_board_images_board_id ON board_images (board_id);",
"CREATE INDEX IF NOT EXISTS idx_board_images_board_id_created_at ON board_images (board_id, created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_board_images_updated_at
AFTER UPDATE
ON board_images FOR EACH ROW
BEGIN
UPDATE board_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE board_id = old.board_id AND image_name = old.image_name;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_boards(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `boards` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS boards (
board_id TEXT NOT NULL PRIMARY KEY,
board_name TEXT NOT NULL,
cover_image_name TEXT,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
FOREIGN KEY (cover_image_name) REFERENCES images (image_name) ON DELETE SET NULL
);
"""
]
indices = ["CREATE INDEX IF NOT EXISTS idx_boards_created_at ON boards (created_at);"]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_boards_updated_at
AFTER UPDATE
ON boards FOR EACH ROW
BEGIN
UPDATE boards SET updated_at = current_timestamp
WHERE board_id = old.board_id;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_images(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `images` table, indices and triggers. Adds the `starred` column."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS images (
image_name TEXT NOT NULL PRIMARY KEY,
-- This is an enum in python, unrestricted string here for flexibility
image_origin TEXT NOT NULL,
-- This is an enum in python, unrestricted string here for flexibility
image_category TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
session_id TEXT,
node_id TEXT,
metadata TEXT,
is_intermediate BOOLEAN DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME
);
"""
]
indices = [
"CREATE UNIQUE INDEX IF NOT EXISTS idx_images_image_name ON images(image_name);",
"CREATE INDEX IF NOT EXISTS idx_images_image_origin ON images(image_origin);",
"CREATE INDEX IF NOT EXISTS idx_images_image_category ON images(image_category);",
"CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_images_updated_at
AFTER UPDATE
ON images FOR EACH ROW
BEGIN
UPDATE images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE image_name = old.image_name;
END;
"""
]
# Add the 'starred' column to `images` if it doesn't exist
cursor.execute("PRAGMA table_info(images)")
columns = [column[1] for column in cursor.fetchall()]
if "starred" not in columns:
tables.append("ALTER TABLE images ADD COLUMN starred BOOLEAN DEFAULT FALSE;")
indices.append("CREATE INDEX IF NOT EXISTS idx_images_starred ON images(starred);")
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_model_config(self, cursor: sqlite3.Cursor) -> None:
"""Creates the `model_config` table, `model_manager_metadata` table, indices and triggers."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS model_config (
id TEXT NOT NULL PRIMARY KEY,
-- The next 3 fields are enums in python, unrestricted string here
base TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
original_hash TEXT, -- could be null
-- Serialized JSON representation of the whole config object,
-- which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
""",
"""--sql
CREATE TABLE IF NOT EXISTS model_manager_metadata (
metadata_key TEXT NOT NULL PRIMARY KEY,
metadata_value TEXT NOT NULL
);
""",
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS model_config_updated_at
AFTER UPDATE
ON model_config FOR EACH ROW
BEGIN
UPDATE model_config SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS base_index ON model_config(base);",
"CREATE INDEX IF NOT EXISTS type_index ON model_config(type);",
"CREATE INDEX IF NOT EXISTS name_index ON model_config(name);",
"CREATE UNIQUE INDEX IF NOT EXISTS path_index ON model_config(path);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_session_queue(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS session_queue (
item_id INTEGER PRIMARY KEY AUTOINCREMENT, -- used for ordering, cursor pagination
batch_id TEXT NOT NULL, -- identifier of the batch this queue item belongs to
queue_id TEXT NOT NULL, -- identifier of the queue this queue item belongs to
session_id TEXT NOT NULL UNIQUE, -- duplicated data from the session column, for ease of access
field_values TEXT, -- NULL if no values are associated with this queue item
session TEXT NOT NULL, -- the session to be executed
status TEXT NOT NULL DEFAULT 'pending', -- the status of the queue item, one of 'pending', 'in_progress', 'completed', 'failed', 'canceled'
priority INTEGER NOT NULL DEFAULT 0, -- the priority, higher is more important
error TEXT, -- any errors associated with this queue item
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), -- updated via trigger
started_at DATETIME, -- updated via trigger
completed_at DATETIME -- updated via trigger, completed items are cleaned up on application startup
-- Ideally this is a FK, but graph_executions uses INSERT OR REPLACE, and REPLACE triggers the ON DELETE CASCADE...
-- FOREIGN KEY (session_id) REFERENCES graph_executions (id) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_item_id ON session_queue(item_id);",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_session_id ON session_queue(session_id);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_batch_id ON session_queue(batch_id);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_created_priority ON session_queue(priority);",
"CREATE INDEX IF NOT EXISTS idx_session_queue_created_status ON session_queue(status);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_completed_at
AFTER UPDATE OF status ON session_queue
FOR EACH ROW
WHEN
NEW.status = 'completed'
OR NEW.status = 'failed'
OR NEW.status = 'canceled'
BEGIN
UPDATE session_queue
SET completed_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = NEW.item_id;
END;
""",
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_started_at
AFTER UPDATE OF status ON session_queue
FOR EACH ROW
WHEN
NEW.status = 'in_progress'
BEGIN
UPDATE session_queue
SET started_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = NEW.item_id;
END;
""",
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_session_queue_updated_at
AFTER UPDATE
ON session_queue FOR EACH ROW
BEGIN
UPDATE session_queue
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE item_id = old.item_id;
END;
""",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_workflow_images(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflow_images (
workflow_id TEXT NOT NULL,
image_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Soft delete, currently unused
deleted_at DATETIME,
-- enforce one-to-many relationship between workflows and images using PK
-- (we can extend this to many-to-many later)
PRIMARY KEY (image_name),
FOREIGN KEY (workflow_id) REFERENCES workflows (workflow_id) ON DELETE CASCADE,
FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE
);
"""
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id ON workflow_images (workflow_id);",
"CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id_created_at ON workflow_images (workflow_id, created_at);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflow_images_updated_at
AFTER UPDATE
ON workflow_images FOR EACH ROW
BEGIN
UPDATE workflow_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id AND image_name = old.image_name;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _create_workflows(self, cursor: sqlite3.Cursor) -> None:
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflows (
workflow TEXT NOT NULL,
workflow_id TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.id')) VIRTUAL NOT NULL UNIQUE, -- gets implicit index
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) -- updated via trigger
);
"""
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflows_updated_at
AFTER UPDATE
ON workflows FOR EACH ROW
BEGIN
UPDATE workflows
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id;
END;
"""
]
for stmt in tables + triggers:
cursor.execute(stmt)
def build_migration_1() -> Migration:
"""
Builds the migration from database version 0 (init) to 1.
This migration represents the state of the database circa InvokeAI v3.4.0, which was the last
version to not use migrations to manage the database.
As such, this migration does include some ALTER statements, and the SQL statements are written
to be idempotent.
- Create `board_images` junction table
- Create `boards` table
- Create `images` table, add `starred` column
- Create `model_config` table
- Create `session_queue` table
- Create `workflow_images` junction table
- Create `workflows` table
"""
migration_1 = Migration(
id="migration_1",
depends_on=None,
from_version=0,
to_version=1,
callback=Migration1Callback(),
)
return migration_1
@@ -0,0 +1,37 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration10Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_error_cols(cursor)
def _update_error_cols(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `error_type` and `error_message` columns to the session queue table.
- Renames the `error` column to `error_traceback`.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN error_type TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN error_message TEXT;")
cursor.execute("ALTER TABLE session_queue RENAME COLUMN error TO error_traceback;")
def build_migration_10() -> Migration:
"""
Build the migration from database version 9 to 10.
This migration does the following:
- Adds `error_type` and `error_message` columns to the session queue table.
- Renames the `error` column to `error_traceback`.
"""
migration_10 = Migration(
id="migration_10",
depends_on="migration_9",
from_version=9,
to_version=10,
callback=Migration10Callback(),
)
return migration_10
@@ -0,0 +1,77 @@
import shutil
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
LEGACY_CORE_MODELS = [
# OpenPose
"any/annotators/dwpose/yolox_l.onnx",
"any/annotators/dwpose/dw-ll_ucoco_384.onnx",
# DepthAnything
"any/annotators/depth_anything/depth_anything_vitl14.pth",
"any/annotators/depth_anything/depth_anything_vitb14.pth",
"any/annotators/depth_anything/depth_anything_vits14.pth",
# Lama inpaint
"core/misc/lama/lama.pt",
# RealESRGAN upscale
"core/upscaling/realesrgan/RealESRGAN_x4plus.pth",
"core/upscaling/realesrgan/RealESRGAN_x4plus_anime_6B.pth",
"core/upscaling/realesrgan/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth",
"core/upscaling/realesrgan/RealESRGAN_x2plus.pth",
]
class Migration11Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._remove_convert_cache()
self._remove_downloaded_models()
self._remove_unused_core_models()
def _remove_convert_cache(self) -> None:
"""Rename models/.cache to models/.convert_cache."""
self._logger.info("Removing models/.cache directory. Converted models will now be cached in .convert_cache.")
legacy_convert_path = self._app_config.root_path / "models" / ".cache"
shutil.rmtree(legacy_convert_path, ignore_errors=True)
def _remove_downloaded_models(self) -> None:
"""Remove models from their old locations; they will re-download when needed."""
self._logger.info(
"Removing legacy just-in-time models. Downloaded models will now be cached in .download_cache."
)
for model_path in LEGACY_CORE_MODELS:
legacy_dest_path = self._app_config.models_path / model_path
legacy_dest_path.unlink(missing_ok=True)
def _remove_unused_core_models(self) -> None:
"""Remove unused core models and their directories."""
self._logger.info("Removing defunct core models.")
for dir in ["face_restoration", "misc", "upscaling"]:
path_to_remove = self._app_config.models_path / "core" / dir
shutil.rmtree(path_to_remove, ignore_errors=True)
shutil.rmtree(self._app_config.models_path / "any" / "annotators", ignore_errors=True)
def build_migration_11(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""
Build the migration from database version 10 to 11.
This migration does the following:
- Moves "core" models previously downloaded with download_with_progress_bar() into new
"models/.download_cache" directory.
- Renames "models/.cache" to "models/.convert_cache".
"""
migration_11 = Migration(
id="migration_11",
depends_on="migration_10",
from_version=10,
to_version=11,
callback=Migration11Callback(app_config=app_config, logger=logger),
)
return migration_11
@@ -0,0 +1,37 @@
import shutil
import sqlite3
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration12Callback:
def __init__(self, app_config: InvokeAIAppConfig) -> None:
self._app_config = app_config
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._remove_model_convert_cache_dir()
def _remove_model_convert_cache_dir(self) -> None:
"""
Removes unused model convert cache directory
"""
convert_cache = self._app_config.convert_cache_path
shutil.rmtree(convert_cache, ignore_errors=True)
def build_migration_12(app_config: InvokeAIAppConfig) -> Migration:
"""
Build the migration from database version 11 to 12.
This migration removes the now-unused model convert cache directory.
"""
migration_12 = Migration(
id="migration_12",
depends_on="migration_11",
from_version=11,
to_version=12,
callback=Migration12Callback(app_config),
)
return migration_12
@@ -0,0 +1,33 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration13Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_archived_col(cursor)
def _add_archived_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `archived` columns to the board table.
"""
cursor.execute("ALTER TABLE boards ADD COLUMN archived BOOLEAN DEFAULT FALSE;")
def build_migration_13() -> Migration:
"""
Build the migration from database version 12 to 13..
This migration does the following:
- Adds `archived` columns to the board table.
"""
migration_13 = Migration(
id="migration_13",
depends_on="migration_12",
from_version=12,
to_version=13,
callback=Migration13Callback(),
)
return migration_13
@@ -0,0 +1,63 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration14Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_style_presets(cursor)
def _create_style_presets(self, cursor: sqlite3.Cursor) -> None:
"""Create the table used to store style presets."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS style_presets (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
preset_data TEXT NOT NULL,
type TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS style_presets
AFTER UPDATE
ON style_presets FOR EACH ROW
BEGIN
UPDATE style_presets SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS idx_style_presets_name ON style_presets(name);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def build_migration_14() -> Migration:
"""
Build the migration from database version 13 to 14..
This migration does the following:
- Create the table used to store style presets.
"""
migration_14 = Migration(
id="migration_14",
depends_on="migration_13",
from_version=13,
to_version=14,
callback=Migration14Callback(),
)
return migration_14
@@ -0,0 +1,36 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration15Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_origin_col(cursor)
def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
def build_migration_15() -> Migration:
"""
Build the migration from database version 14 to 15.
This migration does the following:
- Adds `origin` column to the session queue table.
- Adds `destination` column to the session queue table.
"""
migration_15 = Migration(
id="migration_15",
depends_on="migration_14",
from_version=14,
to_version=15,
callback=Migration15Callback(),
)
return migration_15
@@ -0,0 +1,33 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration16Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_retried_from_item_id_col(cursor)
def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `retried_from_item_id` column to the session queue table.
"""
cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
def build_migration_16() -> Migration:
"""
Build the migration from database version 15 to 16.
This migration does the following:
- Adds `retried_from_item_id` column to the session queue table.
"""
migration_16 = Migration(
id="migration_16",
depends_on="migration_15",
from_version=15,
to_version=16,
callback=Migration16Callback(),
)
return migration_16
@@ -0,0 +1,37 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration17Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_workflows_tags_col(cursor)
def _add_workflows_tags_col(self, cursor: sqlite3.Cursor) -> None:
"""
- Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
workflow JSON.
"""
cursor.execute(
"ALTER TABLE workflow_library ADD COLUMN tags TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.tags')) VIRTUAL;"
)
def build_migration_17() -> Migration:
"""
Build the migration from database version 16 to 17.
This migration does the following:
- Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
workflow JSON.
"""
migration_17 = Migration(
id="migration_17",
depends_on="migration_16",
from_version=16,
to_version=17,
callback=Migration17Callback(),
)
return migration_17
@@ -0,0 +1,49 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration18Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._make_workflow_opened_at_nullable(cursor)
def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
"""
Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
- Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
# For index renaming in SQLite, we need to drop and recreate
cursor.execute("DROP INDEX IF EXISTS idx_workflow_library_opened_at;")
# Rename existing column to deprecated
cursor.execute("ALTER TABLE workflow_library DROP COLUMN opened_at;")
# Add new nullable column - all values will be NULL - no migration of data needed
cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
# Create new index on the new column
cursor.execute(
"CREATE INDEX idx_workflow_library_opened_at ON workflow_library(opened_at);",
)
def build_migration_18() -> Migration:
"""
Build the migration from database version 17 to 18.
This migration does the following:
- Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
- Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
- Dropping the existing `opened_at` column
- Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
- Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
"""
migration_18 = Migration(
id="migration_18",
depends_on="migration_17",
from_version=17,
to_version=18,
callback=Migration18Callback(),
)
return migration_18
@@ -0,0 +1,39 @@
import sqlite3
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
class Migration19Callback:
def __init__(self, app_config: InvokeAIAppConfig):
self.models_path = app_config.models_path
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._populate_size(cursor)
self._add_size_column(cursor)
def _add_size_column(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"ALTER TABLE models ADD COLUMN file_size INTEGER "
"GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL"
)
def _populate_size(self, cursor: sqlite3.Cursor) -> None:
all_models = cursor.execute("SELECT id, path FROM models;").fetchall()
for model_id, model_path in all_models:
mod = ModelOnDisk(self.models_path / model_path)
cursor.execute(
"UPDATE models SET config = json_set(config, '$.file_size', ?) WHERE id = ?", (mod.size(), model_id)
)
def build_migration_19(app_config: InvokeAIAppConfig) -> Migration:
return Migration(
id="migration_19",
depends_on="migration_18",
from_version=18,
to_version=19,
callback=Migration19Callback(app_config),
)
@@ -0,0 +1,168 @@
import sqlite3
from logging import Logger
from pydantic import ValidationError
from tqdm import tqdm
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.app.services.workflow_records.workflow_records_common import (
UnsafeWorkflowWithVersionValidator,
)
class Migration2Callback:
def __init__(self, image_files: ImageFileStorageBase, logger: Logger):
self._image_files = image_files
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor):
self._add_images_has_workflow(cursor)
self._add_session_queue_workflow(cursor)
self._drop_old_workflow_tables(cursor)
self._add_workflow_library(cursor)
self._drop_model_manager_metadata(cursor)
self._migrate_embedded_workflows(cursor)
def _add_images_has_workflow(self, cursor: sqlite3.Cursor) -> None:
"""Add the `has_workflow` column to `images` table."""
cursor.execute("PRAGMA table_info(images)")
columns = [column[1] for column in cursor.fetchall()]
if "has_workflow" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN has_workflow BOOLEAN DEFAULT FALSE;")
def _add_session_queue_workflow(self, cursor: sqlite3.Cursor) -> None:
"""Add the `workflow` column to `session_queue` table."""
cursor.execute("PRAGMA table_info(session_queue)")
columns = [column[1] for column in cursor.fetchall()]
if "workflow" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow TEXT;")
def _drop_old_workflow_tables(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `workflows` and `workflow_images` tables."""
cursor.execute("DROP TABLE IF EXISTS workflow_images;")
cursor.execute("DROP TABLE IF EXISTS workflows;")
def _add_workflow_library(self, cursor: sqlite3.Cursor) -> None:
"""Adds the `workflow_library` table and drops the `workflows` and `workflow_images` tables."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS workflow_library (
workflow_id TEXT NOT NULL PRIMARY KEY,
workflow TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- updated manually when retrieving workflow
opened_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Generated columns, needed for indexing and searching
category TEXT GENERATED ALWAYS as (json_extract(workflow, '$.meta.category')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(workflow, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(workflow, '$.description')) VIRTUAL NOT NULL
);
""",
]
indices = [
"CREATE INDEX IF NOT EXISTS idx_workflow_library_created_at ON workflow_library(created_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_updated_at ON workflow_library(updated_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_opened_at ON workflow_library(opened_at);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_category ON workflow_library(category);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_name ON workflow_library(name);",
"CREATE INDEX IF NOT EXISTS idx_workflow_library_description ON workflow_library(description);",
]
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_workflow_library_updated_at
AFTER UPDATE
ON workflow_library FOR EACH ROW
BEGIN
UPDATE workflow_library
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE workflow_id = old.workflow_id;
END;
"""
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `model_manager_metadata` table."""
cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;")
def _migrate_embedded_workflows(self, cursor: sqlite3.Cursor) -> None:
"""
In the v3.5.0 release, InvokeAI changed how it handles embedded workflows. The `images` table in
the database now has a `has_workflow` column, indicating if an image has a workflow embedded.
This migrate callback checks each image for the presence of an embedded workflow, then updates its entry
in the database accordingly.
"""
# Get all image names
cursor.execute("SELECT image_name FROM images")
image_names: list[str] = [image[0] for image in cursor.fetchall()]
total_image_names = len(image_names)
if not total_image_names:
return
self._logger.info(f"Migrating workflows for {total_image_names} images")
# Migrate the images
to_migrate: list[tuple[bool, str]] = []
pbar = tqdm(image_names)
for idx, image_name in enumerate(pbar):
pbar.set_description(f"Checking image {idx + 1}/{total_image_names} for workflow")
try:
pil_image = self._image_files.get(image_name)
except ImageFileNotFoundException:
self._logger.warning(f"Image {image_name} not found, skipping")
continue
except Exception as e:
self._logger.warning(f"Error while checking image {image_name}, skipping: {e}")
continue
if "invokeai_workflow" in pil_image.info:
try:
UnsafeWorkflowWithVersionValidator.validate_json(pil_image.info.get("invokeai_workflow", ""))
except ValidationError:
self._logger.warning(f"Image {image_name} has invalid embedded workflow, skipping")
continue
to_migrate.append((True, image_name))
self._logger.info(f"Adding {len(to_migrate)} embedded workflows to database")
cursor.executemany("UPDATE images SET has_workflow = ? WHERE image_name = ?", to_migrate)
def build_migration_2(image_files: ImageFileStorageBase, logger: Logger) -> Migration:
"""
Builds the migration from database version 1 to 2.
Introduced in v3.5.0 for the new workflow library.
:param image_files: The image files service, used to check for embedded workflows
:param logger: The logger, used to log progress during embedded workflows handling
This migration does the following:
- Add `has_workflow` column to `images` table
- Add `workflow` column to `session_queue` table
- Drop `workflows` and `workflow_images` tables
- Add `workflow_library` table
- Drops the `model_manager_metadata` table
- Drops the `model_config` table, recreating it (at this point, there is no user data in this table)
- Populates the `has_workflow` column in the `images` table (requires `image_files` & `logger` dependencies)
"""
migration_2 = Migration(
id="migration_2",
depends_on="migration_1",
from_version=1,
to_version=2,
callback=Migration2Callback(image_files=image_files, logger=logger),
)
return migration_2
@@ -0,0 +1,39 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration20Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""
-- many-to-many relationship table for models
CREATE TABLE IF NOT EXISTS model_relationships (
-- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
model_key_1 TEXT NOT NULL,
model_key_2 TEXT NOT NULL,
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (model_key_1, model_key_2),
-- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
);
"""
)
cursor.execute(
"""
-- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
ON model_relationships(model_key_2)
"""
)
def build_migration_20() -> Migration:
return Migration(
id="migration_20",
depends_on="migration_19",
from_version=19,
to_version=20,
callback=Migration20Callback(),
)
@@ -0,0 +1,53 @@
"""Add workflow-call relationship columns to session_queue."""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class AddWorkflowCallQueueMetadataCallback:
"""Add durable parent/child workflow-call relationship columns to session_queue."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "workflow_call_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_id TEXT;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_id ON session_queue(workflow_call_id);"
)
if "parent_item_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_item_id INTEGER;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_parent_item_id ON session_queue(parent_item_id);"
)
if "parent_session_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN parent_session_id TEXT;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_parent_session_id ON session_queue(parent_session_id);"
)
if "root_item_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN root_item_id INTEGER;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_root_item_id ON session_queue(root_item_id);")
if "workflow_call_depth" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow_call_depth INTEGER;")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_queue_workflow_call_depth ON session_queue(workflow_call_depth);"
)
def build_migration() -> Migration:
return Migration(
id="2026_07_01_add_workflow_call_queue_metadata",
depends_on="migration_33",
callback=AddWorkflowCallQueueMetadataCallback(),
)
@@ -0,0 +1,60 @@
"""Add indexes supporting round-robin dequeue.
The round-robin dequeue in multiuser mode relies on two access shapes:
1. A per-user "best pending item" selection (``status = 'pending'`` partitioned by
``user_id`` and ordered by ``priority DESC, item_id ASC``).
2. A per-candidate "last served" lookup (``MAX(started_at)`` for a single ``user_id``),
evaluated as a correlated subquery once per user that has pending work.
With only the pre-existing single-column indexes on ``status``, ``priority``, and
``user_id``, the pending selection falls back to scanning the table and the last-served
lookup scans all retained history. Because completed/failed/canceled history is retained
(and ``max_queue_history`` defaults to unbounded), that cost would grow with total queue
history rather than with the number of pending items / active users.
``idx_session_queue_round_robin_pending`` lets the planner satisfy the pending selection
without touching historical rows. ``idx_session_queue_user_started_at`` turns each
``MAX(started_at) WHERE user_id = ?`` into an indexed seek (the planner's min/max
optimization reads the tail of the user's index range) rather than a scan, so the
last-served lookup costs ``O(log n)`` per active user instead of scanning history.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class RoundRobinIndexesCallback:
"""Add composite indexes matching the round-robin dequeue query shapes."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
# Pending-item selection: WHERE status = 'pending', PARTITION BY user_id,
# ORDER BY priority DESC, item_id ASC.
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_session_queue_round_robin_pending
ON session_queue (status, user_id, priority DESC, item_id ASC);
"""
)
# Last-served lookup: MAX(started_at) WHERE user_id = ?)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_session_queue_user_started_at
ON session_queue (user_id, started_at);
"""
)
def build_migration() -> Migration:
return Migration(
id="2026_07_03_round_robin_indexes",
# migration_27 added the user_id column to session_queue, which both indexes cover.
depends_on="migration_27",
callback=RoundRobinIndexesCallback(),
)
@@ -0,0 +1,42 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration21Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""
CREATE TABLE client_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
data TEXT NOT NULL, -- Frontend will handle the shape of this data
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
"""
)
cursor.execute(
"""
CREATE TRIGGER tg_client_state_updated_at
AFTER UPDATE ON client_state
FOR EACH ROW
BEGIN
UPDATE client_state
SET updated_at = CURRENT_TIMESTAMP
WHERE id = OLD.id;
END;
"""
)
def build_migration_21() -> Migration:
"""Builds the migration object for migrating from version 20 to version 21. This includes:
- Creating the `client_state` table.
- Adding a trigger to update the `updated_at` field on updates.
"""
return Migration(
id="migration_21",
depends_on="migration_20",
from_version=20,
to_version=21,
callback=Migration21Callback(),
)
@@ -0,0 +1,91 @@
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration22Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._logger.info("Removing UNIQUE(name, base, type) constraint from models table")
# Step 1: Rename the existing models table
cursor.execute("ALTER TABLE models RENAME TO models_old;")
# Step 2: Create the new models table without the UNIQUE(name, base, type) constraint
cursor.execute(
"""--sql
CREATE TABLE models (
id TEXT NOT NULL PRIMARY KEY,
hash TEXT GENERATED ALWAYS as (json_extract(config, '$.hash')) VIRTUAL NOT NULL,
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(config, '$.description')) VIRTUAL,
source TEXT GENERATED ALWAYS as (json_extract(config, '$.source')) VIRTUAL NOT NULL,
source_type TEXT GENERATED ALWAYS as (json_extract(config, '$.source_type')) VIRTUAL NOT NULL,
source_api_response TEXT GENERATED ALWAYS as (json_extract(config, '$.source_api_response')) VIRTUAL,
trigger_phrases TEXT GENERATED ALWAYS as (json_extract(config, '$.trigger_phrases')) VIRTUAL,
file_size INTEGER GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL,
-- Serialized JSON representation of the whole config object, which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Explicit unique constraint on path
UNIQUE(path)
);
"""
)
# Step 3: Copy all data from the old table to the new table
# Only copy the stored columns (id, config, created_at, updated_at), not the virtual columns
cursor.execute(
"INSERT INTO models (id, config, created_at, updated_at) "
"SELECT id, config, created_at, updated_at FROM models_old;"
)
# Step 4: Drop the old table
cursor.execute("DROP TABLE models_old;")
# Step 5: Recreate indexes
cursor.execute("CREATE INDEX IF NOT EXISTS base_index ON models(base);")
cursor.execute("CREATE INDEX IF NOT EXISTS type_index ON models(type);")
cursor.execute("CREATE INDEX IF NOT EXISTS name_index ON models(name);")
# Step 6: Recreate the updated_at trigger
cursor.execute(
"""--sql
CREATE TRIGGER models_updated_at
AFTER UPDATE
ON models FOR EACH ROW
BEGIN
UPDATE models SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_22(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 21 to version 22.
This migration:
- Removes the UNIQUE constraint on the combination of (base, name, type) columns in the models table
- Adds an explicit UNIQUE contraint on the path column
"""
return Migration(
id="migration_22",
depends_on="migration_21",
from_version=21,
to_version=22,
callback=Migration22Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,195 @@
import json
import sqlite3
from copy import deepcopy
from logging import Logger
from typing import Any
from pydantic import ValidationError
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.configs.factory import AnyModelConfig, AnyModelConfigValidator
from invokeai.backend.model_manager.configs.unknown import Unknown_Config
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ClipVariantType,
FluxVariantType,
ModelFormat,
ModelType,
ModelVariantType,
SchedulerPredictionType,
)
class Migration23Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
# Grab all model records
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_count = 0
fallback_count = 0
for model_id, config_json in rows:
try:
# Migrate the config JSON to the latest schema
config_dict: dict[str, Any] = json.loads(config_json)
migrated_config = self._parse_and_migrate_config(config_dict)
if isinstance(migrated_config, Unknown_Config):
fallback_count += 1
else:
migrated_count += 1
# Write the migrated config back to the database
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(migrated_config.model_dump_json(), model_id),
)
except ValidationError as e:
self._logger.error("Invalid config schema for model %s: %s", model_id, e)
raise
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
if migrated_count > 0 and fallback_count == 0:
self._logger.info(f"Migration complete: {migrated_count} model configs migrated")
elif migrated_count > 0 and fallback_count > 0:
self._logger.warning(
f"Migration complete: {migrated_count} model configs migrated, "
f"{fallback_count} model configs could not be migrated and were saved as unknown models",
)
elif migrated_count == 0 and fallback_count > 0:
self._logger.warning(
f"Migration complete: all {fallback_count} model configs could not be migrated and were saved as unknown models",
)
else:
self._logger.info("Migration complete: no model configs needed migration")
def _parse_and_migrate_config(self, config_dict: dict[str, Any]) -> AnyModelConfig:
# In v6.9.0 we made some improvements to the model taxonomy and the model config schemas. There are a changes
# we need to make to old configs to bring them up to date.
type = config_dict.get("type")
format = config_dict.get("format")
base = config_dict.get("base")
if base == BaseModelType.Flux.value and type == ModelType.Main.value:
# Prior to v6.9.0, we used an awkward combination of `config_path` and `variant` to distinguish between FLUX
# variants.
#
# `config_path` was set to one of:
# - flux-dev
# - flux-dev-fill
# - flux-schnell
#
# `variant` was set to ModelVariantType.Inpaint for FLUX Fill models and ModelVariantType.Normal for all other FLUX
# models.
#
# We now use the `variant` field to directly represent the FLUX variant type, and `config_path` is no longer used.
# Extract and remove `config_path` if present.
config_path = config_dict.pop("config_path", None)
match config_path:
case "flux-dev":
config_dict["variant"] = FluxVariantType.Dev.value
case "flux-dev-fill":
config_dict["variant"] = FluxVariantType.DevFill.value
case "flux-schnell":
config_dict["variant"] = FluxVariantType.Schnell.value
case _:
# Unknown config_path - default to Dev variant
config_dict["variant"] = FluxVariantType.Dev.value
if (
base
in {
BaseModelType.StableDiffusion1.value,
BaseModelType.StableDiffusion2.value,
BaseModelType.StableDiffusionXL.value,
BaseModelType.StableDiffusionXLRefiner.value,
}
and type == ModelType.Main.value
):
# Prior to v6.9.0, the prediction_type field was optional and would default to Epsilon if not present.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# Epsilon, matching the probe logic.
#
# It's only on SD1.x, SD2.x, and SDXL main models.
config_dict["prediction_type"] = config_dict.get("prediction_type", SchedulerPredictionType.Epsilon.value)
# Prior to v6.9.0, the variant field was optional and would default to Normal if not present.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# Normal. It's only on SD main models.
config_dict["variant"] = config_dict.get("variant", ModelVariantType.Normal.value)
if base == BaseModelType.Flux.value and type == ModelType.LoRA.value and format == ModelFormat.Diffusers.value:
# Prior to v6.9.0, we used the Diffusers format for FLUX LoRA models that used the diffusers _key_
# structure. This was misleading, as everywhere else in the application, we used the Diffusers format
# to indicate that the model files were in the Diffusers _file_ format (i.e. a directory containing
# the weights and config files).
#
# At runtime, we check the LoRA's state dict directly to determine the key structure, so we do not need
# to rely on the format field for this purpose. As of v6.9.0, we always use the LyCORIS format for single-
# file LoRAs, regardless of the key structure.
#
# This change allows LoRA model identification to not need a special case for FLUX LoRAs in the diffusers
# key format.
config_dict["format"] = ModelFormat.LyCORIS.value
if type == ModelType.CLIPVision.value:
# Prior to v6.9.0, some CLIP Vision models were associated with a specific base model architecture:
# - CLIP-ViT-bigG-14-laion2B-39B-b160k is the image encoder for SDXL IP Adapter and was associated with SDXL
# - CLIP-ViT-H-14-laion2B-s32B-b79K is the image encoder for SD1.5 IP Adapter and was associated with SD1.5
#
# While this made some sense at the time, it is more correct and flexible to treat CLIP Vision models
# as independent of any specific base model architecture.
config_dict["base"] = BaseModelType.Any.value
if type == ModelType.CLIPEmbed.value:
# Prior to v6.9.0, some CLIP Embed models did not have a variant set. The default was the L variant.
# We now make it explicit and always present. Use the existing value if present, otherwise default to
# L variant. Also, treat CLIP Embed models as independent of any specific base model architecture.
config_dict["base"] = BaseModelType.Any.value
config_dict["variant"] = config_dict.get("variant", ClipVariantType.L.value)
try:
migrated_config = AnyModelConfigValidator.validate_python(config_dict)
# This could be a ValidationError or any other error that occurs during validation. A failure to generate a
# union discriminator could raise a ValueError, for example. Who knows what else could fail - catch all.
except Exception as e:
self._logger.error("Failed to validate migrated config, attempting to save as unknown model: %s", e)
cloned_config_dict = deepcopy(config_dict)
cloned_config_dict.pop("base", None)
cloned_config_dict.pop("type", None)
cloned_config_dict.pop("format", None)
migrated_config = Unknown_Config(
**cloned_config_dict,
base=BaseModelType.Unknown,
type=ModelType.Unknown,
format=ModelFormat.Unknown,
)
return migrated_config
def build_migration_23(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 22 to version 23.
This migration updates model configurations to the latest config schemas for v6.9.0.
"""
return Migration(
id="migration_23",
depends_on="migration_22",
from_version=22,
to_version=23,
callback=Migration23Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,242 @@
import json
import sqlite3
from logging import Logger
from pathlib import Path
from typing import NamedTuple
from pydantic import ValidationError
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.configs.factory import AnyModelConfigValidator
class NormalizeResult(NamedTuple):
new_relative_path: str | None
rollback_ops: list[tuple[Path, Path]]
class Migration24Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
self._models_dir = app_config.models_path.resolve()
def __call__(self, cursor: sqlite3.Cursor) -> None:
# Grab all model records
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
for model_id, config_json in rows:
try:
config = AnyModelConfigValidator.validate_json(config_json)
except ValidationError:
# This could happen if the config schema changed in a way that makes old configs invalid. Unlikely
# for users, more likely for devs testing out migration paths.
self._logger.warning("Skipping model %s: invalid config schema", model_id)
continue
except json.JSONDecodeError:
# This should never happen, as we use pydantic to serialize the config to JSON.
self._logger.warning("Skipping model %s: invalid config JSON", model_id)
continue
# We'll use a savepoint so we can roll back the database update if something goes wrong, and a simple
# rollback of file operations if needed.
cursor.execute("SAVEPOINT migrate_model")
try:
new_relative_path, rollback_ops = self._normalize_model_storage(
key=config.key,
path_value=config.path,
)
except Exception as err:
self._logger.error("Error normalizing model %s: %s", config.key, err)
cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
cursor.execute("RELEASE SAVEPOINT migrate_model")
continue
if new_relative_path is None:
cursor.execute("RELEASE SAVEPOINT migrate_model")
continue
config.path = new_relative_path
try:
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(config.model_dump_json(), model_id),
)
except Exception as err:
self._logger.error("Database update failed for model %s: %s", config.key, err)
cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
cursor.execute("RELEASE SAVEPOINT migrate_model")
self._rollback_file_ops(rollback_ops)
continue
cursor.execute("RELEASE SAVEPOINT migrate_model")
self._prune_empty_directories()
def _normalize_model_storage(self, key: str, path_value: str) -> NormalizeResult:
models_dir = self._models_dir
stored_path = Path(path_value)
relative_path: Path | None
if stored_path.is_absolute():
# If the stored path is absolute, we need to check if it's inside the models directory, which means it is
# an Invoke-managed model. If it's outside, it is user-managed we leave it alone.
try:
relative_path = stored_path.resolve().relative_to(models_dir)
except ValueError:
self._logger.info("Leaving user-managed model %s at %s", key, stored_path)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
else:
# Relative paths are always relative to the models directory and thus Invoke-managed.
relative_path = stored_path
# If the relative path is empty, assume something is wrong. Warn and skip.
if not relative_path.parts:
self._logger.warning("Skipping model %s: empty relative path", key)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
# Sanity check: the path is relative. It should be present in the models directory.
absolute_path = (models_dir / relative_path).resolve()
if not absolute_path.exists():
self._logger.warning(
"Skipping model %s: expected model files at %s but nothing was found",
key,
absolute_path,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
if relative_path.parts[0] == key:
# Already normalized. Still ensure the stored path is relative.
normalized_path = relative_path.as_posix()
# If the stored path is already the normalized path, no change is needed.
new_relative_path = normalized_path if stored_path.as_posix() != normalized_path else None
return NormalizeResult(new_relative_path=new_relative_path, rollback_ops=[])
# We'll store the file operations we perform so we can roll them back if needed.
rollback_ops: list[tuple[Path, Path]] = []
# Destination directory is models_dir/<key> - a flat directory structure.
destination_dir = models_dir / key
try:
if absolute_path.is_file():
destination_dir.mkdir(parents=True, exist_ok=True)
dest_file = destination_dir / absolute_path.name
# This really shouldn't happen.
if dest_file.exists():
self._logger.warning(
"Destination for model %s already exists at %s; skipping move",
key,
dest_file,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
self._logger.info("Moving model file %s -> %s", absolute_path, dest_file)
# `Path.rename()` effectively moves the file or directory.
absolute_path.rename(dest_file)
rollback_ops.append((dest_file, absolute_path))
return NormalizeResult(
new_relative_path=(Path(key) / dest_file.name).as_posix(),
rollback_ops=rollback_ops,
)
if absolute_path.is_dir():
dest_path = destination_dir
# This really shouldn't happen.
if dest_path.exists():
self._logger.warning(
"Destination directory %s already exists for model %s; skipping",
dest_path,
key,
)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
self._logger.info("Moving model directory %s -> %s", absolute_path, dest_path)
# `Path.rename()` effectively moves the file or directory.
absolute_path.rename(dest_path)
rollback_ops.append((dest_path, absolute_path))
return NormalizeResult(
new_relative_path=Path(key).as_posix(),
rollback_ops=rollback_ops,
)
# Maybe a broken symlink or something else weird?
self._logger.warning("Skipping model %s: path %s is neither a file nor directory", key, absolute_path)
return NormalizeResult(new_relative_path=None, rollback_ops=[])
except Exception:
self._rollback_file_ops(rollback_ops)
raise
def _rollback_file_ops(self, rollback_ops: list[tuple[Path, Path]]) -> None:
# This is a super-simple rollback that just reverses the move operations we performed.
for source, destination in reversed(rollback_ops):
try:
if source.exists():
source.rename(destination)
except Exception as err:
self._logger.error("Failed to rollback move %s -> %s: %s", source, destination, err)
def _prune_empty_directories(self) -> None:
# These directories are system directories we want to keep even if empty. Technically, the app should not
# have any problems if these are removed, creating them as needed, but it's cleaner to just leave them alone.
keep_names = {"model_images", ".download_cache"}
keep_dirs = {self._models_dir / name for name in keep_names}
removed_dirs: set[Path] = set()
# Walk the models directory tree from the bottom up, removing empty directories. We sort by path length
# descending to ensure we visit children before parents.
for directory in sorted(self._models_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
if not directory.is_dir():
continue
if directory == self._models_dir:
continue
if any(directory == keep or keep in directory.parents for keep in keep_dirs):
continue
try:
next(directory.iterdir())
except StopIteration:
try:
directory.rmdir()
removed_dirs.add(directory)
self._logger.debug("Removed empty directory %s", directory)
except OSError:
# Directory not empty (or some other error) - bail out.
self._logger.warning("Failed to prune directory %s - not empty?", directory)
continue
except OSError:
continue
self._logger.info("Pruned %d empty directories under %s", len(removed_dirs), self._models_dir)
def build_migration_24(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 23 to version 24.
This migration normalizes on-disk model storage so that each model lives within
a directory named by its key inside the Invoke-managed models directory, and
updates database records to reference the new relative paths.
This migration behaves a bit differently than others. Because it involves FS operations, if we rolled the
DB back on any failure, we could leave the FS out of sync with the DB. Instead, we use savepoints
to roll back individual model updates on failure, and we roll back any FS operations we performed
for that model.
If a model cannot be migrated for any reason (invalid config, missing files, FS errors, DB errors), we log a
warning and skip it, leaving it in its original state and location. The model will still work, but it will be in
the "wrong" location on disk.
"""
return Migration(
id="migration_24",
depends_on="migration_23",
from_version=23,
to_version=24,
callback=Migration24Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,63 @@
import json
import sqlite3
from logging import Logger
from typing import Any
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.taxonomy import ModelType, Qwen3VariantType
class Migration25Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_count = 0
for model_id, config_json in rows:
try:
config_dict: dict[str, Any] = json.loads(config_json)
if config_dict.get("type") != ModelType.Qwen3Encoder.value:
continue
if "variant" in config_dict:
continue
config_dict["variant"] = Qwen3VariantType.Qwen3_4B.value
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(json.dumps(config_dict), model_id),
)
migrated_count += 1
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
if migrated_count > 0:
self._logger.info(f"Migration complete: {migrated_count} Qwen3 encoder configs updated with variant field")
else:
self._logger.info("Migration complete: no Qwen3 encoder configs needed migration")
def build_migration_25(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 24 to version 25.
This migration adds the variant field to existing Qwen3 encoder models.
Models installed before the variant field was added will default to Qwen3_4B (for Z-Image compatibility).
"""
return Migration(
id="migration_25",
depends_on="migration_24",
from_version=24,
to_version=25,
callback=Migration25Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,117 @@
import json
import sqlite3
from logging import Logger
from pathlib import Path
from typing import Any
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, ZImageVariantType
class Migration26Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def _detect_variant_from_scheduler(self, model_path: Path) -> ZImageVariantType:
"""Detect Z-Image variant from scheduler config for Diffusers models.
Z-Image variants are distinguished by the scheduler shift value:
- Turbo (distilled): shift = 3.0
- Base (undistilled): shift = 6.0
"""
scheduler_config_path = model_path / "scheduler" / "scheduler_config.json"
if not scheduler_config_path.exists():
return ZImageVariantType.Turbo
try:
with open(scheduler_config_path, "r", encoding="utf-8") as f:
scheduler_config = json.load(f)
shift = scheduler_config.get("shift", 3.0)
# ZBase (undistilled) uses shift = 6.0, Turbo uses shift = 3.0
if shift >= 5.0:
return ZImageVariantType.ZBase
else:
return ZImageVariantType.Turbo
except (json.JSONDecodeError, OSError) as e:
self._logger.warning(f"Could not read scheduler config: {e}, defaulting to Turbo")
return ZImageVariantType.Turbo
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT id, config FROM models;")
rows = cursor.fetchall()
migrated_turbo = 0
migrated_base = 0
for model_id, config_json in rows:
try:
config_dict: dict[str, Any] = json.loads(config_json)
# Only migrate Z-Image main models
if config_dict.get("base") != BaseModelType.ZImage.value:
continue
if config_dict.get("type") != ModelType.Main.value:
continue
# Skip if variant already set
if "variant" in config_dict:
continue
# Determine variant based on format
model_format = config_dict.get("format")
model_path = config_dict.get("path")
if model_format == ModelFormat.Diffusers.value and model_path:
# For Diffusers models, detect from scheduler config
variant = self._detect_variant_from_scheduler(Path(model_path))
else:
# For Checkpoint/GGUF, default to Turbo (Base only available as Diffusers)
variant = ZImageVariantType.Turbo
config_dict["variant"] = variant.value
cursor.execute(
"UPDATE models SET config = ? WHERE id = ?;",
(json.dumps(config_dict), model_id),
)
if variant == ZImageVariantType.ZBase:
migrated_base += 1
else:
migrated_turbo += 1
except json.JSONDecodeError as e:
self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
raise
total = migrated_turbo + migrated_base
if total > 0:
self._logger.info(
f"Migration complete: {total} Z-Image model configs updated "
f"({migrated_turbo} Turbo, {migrated_base} Base)"
)
else:
self._logger.info("Migration complete: no Z-Image model configs needed migration")
def build_migration_26(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""Builds the migration object for migrating from version 25 to version 26.
This migration adds the variant field to existing Z-Image main models.
Models installed before the variant field was added will default to Turbo
(the only variant available before Z-Image Base support was added).
"""
return Migration(
id="migration_26",
depends_on="migration_25",
from_version=25,
to_version=26,
callback=Migration26Callback(app_config=app_config, logger=logger),
)
@@ -0,0 +1,368 @@
"""Migration 27: Add multi-user support, per-user client state, and app settings.
This migration adds the database schema for multi-user support, including:
- users table for user accounts
- user_sessions table for session management
- user_invitations table for invitation system
- shared_boards table for board sharing
- Adding user_id columns to existing tables for data ownership
- Restructuring client_state table to support per-user storage
- app_settings table for storing JWT secret and other app-level settings
"""
import json
import secrets
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration27Callback:
"""Migration to add multi-user support, per-user client state, and app settings."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_users_table(cursor)
self._create_user_sessions_table(cursor)
self._create_user_invitations_table(cursor)
self._create_shared_boards_table(cursor)
self._update_boards_table(cursor)
self._update_images_table(cursor)
self._update_workflows_table(cursor)
self._update_session_queue_table(cursor)
self._update_style_presets_table(cursor)
self._create_system_user(cursor)
self._update_client_state_table(cursor)
self._create_app_settings_table(cursor)
self._generate_jwt_secret(cursor)
def _create_users_table(self, cursor: sqlite3.Cursor) -> None:
"""Create users table."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
user_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_login_at DATETIME
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS tg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN
UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE user_id = old.user_id;
END;
""")
def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None:
"""Create user_sessions table for session management."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_sessions (
session_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);")
def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None:
"""Create user_invitations table for invitation system."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_invitations (
invitation_id TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
invited_by TEXT NOT NULL,
invitation_code TEXT NOT NULL UNIQUE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);")
def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Create shared_boards table for board sharing."""
cursor.execute("""
CREATE TABLE IF NOT EXISTS shared_boards (
board_id TEXT NOT NULL,
user_id TEXT NOT NULL,
can_edit BOOLEAN NOT NULL DEFAULT FALSE,
shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (board_id, user_id),
FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);")
def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to boards table."""
# Check if boards table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
if cursor.fetchone() is None:
return
# Check if user_id column exists
cursor.execute("PRAGMA table_info(boards);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);")
def _update_images_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id column to images table."""
# Check if images table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(images);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);")
def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to workflows table."""
# Check if workflows table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(workflows);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);")
def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id column to session_queue table."""
# Check if session_queue table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);")
def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to style_presets table."""
# Check if style_presets table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(style_presets);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);")
def _create_system_user(self, cursor: sqlite3.Cursor) -> None:
"""Create system user for backward compatibility.
The system user is NOT an admin - it's just used to own existing data
from before multi-user support was added. Real admin users should be
created through the /auth/setup endpoint.
"""
cursor.execute("""
INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active)
VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE);
""")
def _update_client_state_table(self, cursor: sqlite3.Cursor) -> None:
"""Restructure client_state table to support per-user storage."""
# Check if client_state table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='client_state';")
if cursor.fetchone() is None:
# Table doesn't exist, create it with the new schema
cursor.execute(
"""
CREATE TABLE client_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
cursor.execute(
"""
CREATE TRIGGER tg_client_state_updated_at
AFTER UPDATE ON client_state
FOR EACH ROW
BEGIN
UPDATE client_state
SET updated_at = CURRENT_TIMESTAMP
WHERE user_id = OLD.user_id AND key = OLD.key;
END;
"""
)
return
# Table exists with old schema - migrate it
# Get existing data if the data column is present (it may be absent if an older
# version of migration 21 was deployed without the column)
cursor.execute("PRAGMA table_info(client_state);")
columns = [row[1] for row in cursor.fetchall()]
existing_data = {}
if "data" in columns:
cursor.execute("SELECT data FROM client_state WHERE id = 1;")
row = cursor.fetchone()
if row is not None:
try:
existing_data = json.loads(row[0])
except (json.JSONDecodeError, TypeError):
# If data is corrupt, just start fresh
pass
# Drop the old table
cursor.execute("DROP TABLE IF EXISTS client_state;")
# Create new table with per-user schema
cursor.execute(
"""
CREATE TABLE client_state (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
cursor.execute(
"""
CREATE TRIGGER tg_client_state_updated_at
AFTER UPDATE ON client_state
FOR EACH ROW
BEGIN
UPDATE client_state
SET updated_at = CURRENT_TIMESTAMP
WHERE user_id = OLD.user_id AND key = OLD.key;
END;
"""
)
# Migrate existing data to 'system' user
for key, value in existing_data.items():
cursor.execute(
"""
INSERT INTO client_state (user_id, key, value)
VALUES ('system', ?, ?);
""",
(key, value),
)
def _create_app_settings_table(self, cursor: sqlite3.Cursor) -> None:
"""Create app_settings table for storing application-level configuration."""
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
cursor.execute(
"""
CREATE TRIGGER IF NOT EXISTS tg_app_settings_updated_at
AFTER UPDATE ON app_settings
FOR EACH ROW
BEGIN
UPDATE app_settings SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE key = OLD.key;
END;
"""
)
def _generate_jwt_secret(self, cursor: sqlite3.Cursor) -> None:
"""Generate and store a cryptographically secure JWT secret key.
The secret is a 64-character hexadecimal string (256 bits of entropy),
which is suitable for HS256 JWT signing.
"""
# Check if JWT secret already exists
cursor.execute("SELECT value FROM app_settings WHERE key = 'jwt_secret';")
existing_secret = cursor.fetchone()
if existing_secret is None:
# Generate a new cryptographically secure secret (256 bits)
jwt_secret = secrets.token_hex(32) # 32 bytes = 256 bits = 64 hex characters
# Store in database
cursor.execute(
"INSERT INTO app_settings (key, value) VALUES ('jwt_secret', ?);",
(jwt_secret,),
)
def build_migration_27() -> Migration:
"""Builds the migration object for migrating from version 26 to version 27.
This migration adds multi-user support, per-user client state, and app settings
(including a JWT secret) to the database schema.
"""
return Migration(
id="migration_27",
depends_on="migration_26",
from_version=26,
to_version=27,
callback=Migration27Callback(),
)
@@ -0,0 +1,50 @@
"""Migration 28: Add per-user workflow isolation columns to workflow_library.
This migration adds the database columns required for multiuser workflow isolation
to the workflow_library table:
- user_id: the owner of the workflow (defaults to 'system' for existing workflows)
- is_public: whether the workflow is shared with all users
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration28Callback:
"""Migration to add user_id and is_public to the workflow_library table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_workflow_library_table(cursor)
def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
"""Add user_id and is_public columns to workflow_library table."""
cursor.execute("PRAGMA table_info(workflow_library);")
columns = [row[1] for row in cursor.fetchall()]
if "user_id" not in columns:
cursor.execute("ALTER TABLE workflow_library ADD COLUMN user_id TEXT DEFAULT 'system';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);")
if "is_public" not in columns:
cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
cursor.execute(
"UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';"
) # one-time fix for legacy workflows
def build_migration_28() -> Migration:
"""Builds the migration object for migrating from version 27 to version 28.
This migration adds per-user workflow isolation to the workflow_library table:
- user_id column: identifies the owner of each workflow
- is_public column: controls whether a workflow is shared with all users
"""
return Migration(
id="migration_28",
depends_on="migration_27",
from_version=27,
to_version=28,
callback=Migration28Callback(),
)
@@ -0,0 +1,55 @@
"""Migration 29: Add board_visibility column to boards table.
This migration adds a board_visibility column to the boards table to support
three visibility levels:
- 'private': only the board owner (and admins) can view/modify
- 'shared': all users can view, but only the owner (and admins) can modify
- 'public': all users can view; only the owner (and admins) can modify the
board structure (rename/archive/delete)
Existing boards with is_public = 1 are migrated to 'public'.
All other existing boards default to 'private'.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration29Callback:
"""Migration to add board_visibility column to the boards table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_boards_table(cursor)
def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Add board_visibility column to boards table."""
# Check if boards table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(boards);")
columns = [row[1] for row in cursor.fetchall()]
if "board_visibility" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);")
# Migrate existing is_public = 1 boards to 'public'
if "is_public" in columns:
cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;")
def build_migration_29() -> Migration:
"""Builds the migration object for migrating from version 28 to version 29.
This migration adds the board_visibility column to the boards table,
supporting 'private', 'shared', and 'public' visibility levels.
"""
return Migration(
id="migration_29",
depends_on="migration_28",
from_version=28,
to_version=29,
callback=Migration29Callback(),
)
@@ -0,0 +1,72 @@
import sqlite3
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration3Callback:
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_model_manager_metadata(cursor)
self._recreate_model_config(cursor)
def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `model_manager_metadata` table."""
cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;")
def _recreate_model_config(self, cursor: sqlite3.Cursor) -> None:
"""
Drops the `model_config` table, recreating it.
In 3.4.0, this table used explicit columns but was changed to use json_extract 3.5.0.
Because this table is not used in production, we are able to simply drop it and recreate it.
"""
cursor.execute("DROP TABLE IF EXISTS model_config;")
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_config (
id TEXT NOT NULL PRIMARY KEY,
-- The next 3 fields are enums in python, unrestricted string here
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
original_hash TEXT, -- could be null
-- Serialized JSON representation of the whole config object,
-- which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
"""
)
def build_migration_3(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
"""
Build the migration from database version 2 to 3.
This migration does the following:
- Drops the `model_config` table, recreating it
- Migrates data from `models.yaml` into the `model_config` table
"""
migration_3 = Migration(
id="migration_3",
depends_on="migration_2",
from_version=2,
to_version=3,
callback=Migration3Callback(app_config=app_config, logger=logger),
)
return migration_3
@@ -0,0 +1,35 @@
"""Migration 30: Add per-item queue status sequencing.
This migration adds a `status_sequence` column to `session_queue` so queue item
status updates can be ordered across asynchronous event and snapshot channels.
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration30Callback:
"""Add a per-queue-item status sequence for cross-channel ordering."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(session_queue);")
columns = [row[1] for row in cursor.fetchall()]
if "status_sequence" not in columns:
cursor.execute("ALTER TABLE session_queue ADD COLUMN status_sequence INTEGER DEFAULT 0;")
cursor.execute("UPDATE session_queue SET status_sequence = 0 WHERE status_sequence IS NULL;")
def build_migration_30() -> Migration:
return Migration(
id="migration_30",
depends_on="migration_29",
from_version=29,
to_version=30,
callback=Migration30Callback(),
)
@@ -0,0 +1,43 @@
"""Migration 31: Add image_subfolder column to images table.
This migration adds an image_subfolder column to the images table to support
configurable image subfolder strategies (flat, date, type, hash).
Existing images get an empty string (flat/root directory).
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration31Callback:
"""Migration to add image_subfolder column to images table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_image_subfolder_column(cursor)
def _add_image_subfolder_column(self, cursor: sqlite3.Cursor) -> None:
"""Add image_subfolder column to images table."""
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
if cursor.fetchone() is None:
return
cursor.execute("PRAGMA table_info(images);")
columns = [row[1] for row in cursor.fetchall()]
if "image_subfolder" not in columns:
cursor.execute("ALTER TABLE images ADD COLUMN image_subfolder TEXT NOT NULL DEFAULT '';")
def build_migration_31() -> Migration:
"""Builds the migration object for migrating from version 30 to version 31.
This migration adds an image_subfolder column to the images table.
"""
return Migration(
id="migration_31",
depends_on="migration_30",
from_version=30,
to_version=31,
callback=Migration31Callback(),
)
@@ -0,0 +1,93 @@
"""Migration 32: Repair model_relationships foreign keys.
Migration 22 rebuilt the `models` table by renaming it to `models_old`, creating a
fresh `models` table, copying the data over, and dropping `models_old`. Because
modern SQLite (with `legacy_alter_table` off) rewrites foreign-key references in
*other* tables when a table is renamed, the foreign keys in `model_relationships`
were silently repointed at `models_old` -- which was then dropped.
This left the related-models links referencing a table that no longer exists,
breaking `ON DELETE CASCADE` and foreign-key integrity for related models.
This migration rebuilds `model_relationships` so its foreign keys reference
`models(id)` again, preserving existing links and dropping any orphaned rows whose
model keys no longer exist (those would violate the restored foreign keys).
"""
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration32Callback:
"""Migration to repair the broken foreign keys on the model_relationships table."""
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._repair_model_relationships_fks(cursor)
def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None:
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships';")
row = cursor.fetchone()
if row is None:
# Table does not exist (fresh db will create it correctly), nothing to repair.
return
existing_sql: str = row[0]
if "models_old" not in existing_sql:
# Foreign keys already point at the correct table, nothing to repair.
return
# Rebuild the table with the correct foreign keys referencing models(id).
cursor.execute("ALTER TABLE model_relationships RENAME TO model_relationships_old;")
cursor.execute(
"""
-- many-to-many relationship table for models
CREATE TABLE model_relationships (
-- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
model_key_1 TEXT NOT NULL,
model_key_2 TEXT NOT NULL,
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
PRIMARY KEY (model_key_1, model_key_2),
-- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
);
"""
)
# Copy over the existing links, dropping any orphaned rows whose model keys no
# longer exist -- these would violate the restored foreign keys.
cursor.execute(
"""
INSERT INTO model_relationships (model_key_1, model_key_2, created_at)
SELECT model_key_1, model_key_2, created_at
FROM model_relationships_old
WHERE model_key_1 IN (SELECT id FROM models)
AND model_key_2 IN (SELECT id FROM models);
"""
)
# Drop the old table first so its index name is freed before we recreate it.
cursor.execute("DROP TABLE model_relationships_old;")
cursor.execute(
"""
-- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
ON model_relationships(model_key_2);
"""
)
def build_migration_32() -> Migration:
"""Builds the migration object for migrating from version 31 to version 32.
This migration repairs the foreign keys on the model_relationships table, which were
broken by migration 22 rebuilding the models table.
"""
return Migration(
id="migration_32",
depends_on="migration_31",
from_version=31,
to_version=32,
callback=Migration32Callback(),
)
@@ -0,0 +1,72 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration33Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS image_subfolder_move_jobs (
id INTEGER PRIMARY KEY,
state TEXT NOT NULL CHECK (
state IN ('planned', 'moving', 'moved', 'committed', 'error')
),
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
error_message TEXT
);
"""
)
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS image_subfolder_move_items (
job_id INTEGER NOT NULL REFERENCES image_subfolder_move_jobs(id),
image_name TEXT NOT NULL REFERENCES images(image_name),
old_subfolder TEXT NOT NULL,
new_subfolder TEXT NOT NULL,
is_intermediate BOOLEAN NOT NULL DEFAULT FALSE,
old_path TEXT,
new_path TEXT,
old_thumbnail_path TEXT,
new_thumbnail_path TEXT,
state TEXT NOT NULL CHECK (
state IN ('planned', 'moved', 'committed', 'error')
),
error_message TEXT,
PRIMARY KEY (job_id, image_name)
);
"""
)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_job_state
ON image_subfolder_move_items(job_id, state);
"""
)
cursor.execute(
"""--sql
CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_image_name
ON image_subfolder_move_items(image_name);
"""
)
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS tg_image_subfolder_move_jobs_updated_at
AFTER UPDATE
ON image_subfolder_move_jobs FOR EACH ROW
BEGIN
UPDATE image_subfolder_move_jobs
SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_33() -> Migration:
return Migration(
from_version=32,
to_version=33,
callback=Migration33Callback(),
)
@@ -0,0 +1,85 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration4Callback:
"""Callback to do step 4 of migration."""
def __call__(self, cursor: sqlite3.Cursor) -> None: # noqa D102
self._create_model_metadata(cursor)
self._create_model_tags(cursor)
self._create_tags(cursor)
self._create_triggers(cursor)
def _create_model_metadata(self, cursor: sqlite3.Cursor) -> None:
"""Create the table used to store model metadata downloaded from remote sources."""
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_metadata (
id TEXT NOT NULL PRIMARY KEY,
name TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.name')) VIRTUAL NOT NULL,
author TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.author')) VIRTUAL NOT NULL,
-- Serialized JSON representation of the whole metadata object,
-- which will contain additional fields from subclasses
metadata TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY(id) REFERENCES model_config(id) ON DELETE CASCADE
);
"""
)
def _create_model_tags(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS model_tags (
model_id TEXT NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY(model_id) REFERENCES model_config(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE,
UNIQUE(model_id,tag_id)
);
"""
)
def _create_tags(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS tags (
tag_id INTEGER NOT NULL PRIMARY KEY,
tag_text TEXT NOT NULL UNIQUE
);
"""
)
def _create_triggers(self, cursor: sqlite3.Cursor) -> None:
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS model_metadata_updated_at
AFTER UPDATE
ON model_metadata FOR EACH ROW
BEGIN
UPDATE model_metadata SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def build_migration_4() -> Migration:
"""
Build the migration from database version 3 to 4.
Adds the tables needed to store model metadata and tags.
"""
migration_4 = Migration(
id="migration_4",
depends_on="migration_3",
from_version=3,
to_version=4,
callback=Migration4Callback(),
)
return migration_4
@@ -0,0 +1,36 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration5Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_graph_executions(cursor)
def _drop_graph_executions(self, cursor: sqlite3.Cursor) -> None:
"""Drops the `graph_executions` table."""
cursor.execute(
"""--sql
DROP TABLE IF EXISTS graph_executions;
"""
)
def build_migration_5() -> Migration:
"""
Build the migration from database version 4 to 5.
Introduced in v3.6.3, this migration:
- Drops the `graph_executions` table. We are able to do this because we are moving the graph storage
to be purely in-memory.
"""
migration_5 = Migration(
id="migration_5",
depends_on="migration_4",
from_version=4,
to_version=5,
callback=Migration5Callback(),
)
return migration_5
@@ -0,0 +1,64 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration6Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._recreate_model_triggers(cursor)
self._delete_ip_adapters(cursor)
def _recreate_model_triggers(self, cursor: sqlite3.Cursor) -> None:
"""
Adds the timestamp trigger to the model_config table.
This trigger was inadvertently dropped in earlier migration scripts.
"""
cursor.execute(
"""--sql
CREATE TRIGGER IF NOT EXISTS model_config_updated_at
AFTER UPDATE
ON model_config FOR EACH ROW
BEGIN
UPDATE model_config SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
)
def _delete_ip_adapters(self, cursor: sqlite3.Cursor) -> None:
"""
Delete all the IP adapters.
The model manager will automatically find and re-add them after the migration
is done. This allows the manager to add the correct image encoder to their
configuration records.
"""
cursor.execute(
"""--sql
DELETE FROM model_config
WHERE type='ip_adapter';
"""
)
def build_migration_6() -> Migration:
"""
Build the migration from database version 5 to 6.
This migration does the following:
- Adds the model_config_updated_at trigger if it does not exist
- Delete all ip_adapter models so that the model prober can find and
update with the correct image processor model.
"""
migration_6 = Migration(
id="migration_6",
depends_on="migration_5",
from_version=5,
to_version=6,
callback=Migration6Callback(),
)
return migration_6
@@ -0,0 +1,90 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration7Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_models_table(cursor)
self._drop_old_models_tables(cursor)
def _drop_old_models_tables(self, cursor: sqlite3.Cursor) -> None:
"""Drops the old model_records, model_metadata, model_tags and tags tables."""
tables = ["model_config", "model_metadata", "model_tags", "tags"]
for table in tables:
cursor.execute(f"DROP TABLE IF EXISTS {table};")
def _create_models_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the v4.0.0 models table."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS models (
id TEXT NOT NULL PRIMARY KEY,
hash TEXT GENERATED ALWAYS as (json_extract(config, '$.hash')) VIRTUAL NOT NULL,
base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
description TEXT GENERATED ALWAYS as (json_extract(config, '$.description')) VIRTUAL,
source TEXT GENERATED ALWAYS as (json_extract(config, '$.source')) VIRTUAL NOT NULL,
source_type TEXT GENERATED ALWAYS as (json_extract(config, '$.source_type')) VIRTUAL NOT NULL,
source_api_response TEXT GENERATED ALWAYS as (json_extract(config, '$.source_api_response')) VIRTUAL,
trigger_phrases TEXT GENERATED ALWAYS as (json_extract(config, '$.trigger_phrases')) VIRTUAL,
-- Serialized JSON representation of the whole config object, which will contain additional fields from subclasses
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- unique constraint on combo of name, base and type
UNIQUE(name, base, type)
);
"""
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS models_updated_at
AFTER UPDATE
ON models FOR EACH ROW
BEGIN
UPDATE models SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS base_index ON models(base);",
"CREATE INDEX IF NOT EXISTS type_index ON models(type);",
"CREATE INDEX IF NOT EXISTS name_index ON models(name);",
"CREATE UNIQUE INDEX IF NOT EXISTS path_index ON models(path);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def build_migration_7() -> Migration:
"""
Build the migration from database version 6 to 7.
This migration does the following:
- Adds the new models table
- Drops the old model_records, model_metadata, model_tags and tags tables.
- TODO(MM2): Migrates model names and descriptions from `models.yaml` to the new table (?).
"""
migration_7 = Migration(
id="migration_7",
depends_on="migration_6",
from_version=6,
to_version=7,
callback=Migration7Callback(),
)
return migration_7
@@ -0,0 +1,93 @@
import sqlite3
from pathlib import Path
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration8Callback:
def __init__(self, app_config: InvokeAIAppConfig) -> None:
self._app_config = app_config
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._drop_model_config_table(cursor)
self._migrate_abs_models_to_rel(cursor)
def _drop_model_config_table(self, cursor: sqlite3.Cursor) -> None:
"""Drops the old model_config table. This was missed in a previous migration."""
cursor.execute("DROP TABLE IF EXISTS model_config;")
def _migrate_abs_models_to_rel(self, cursor: sqlite3.Cursor) -> None:
"""Check all model paths & legacy config paths to determine if they are inside Invoke-managed directories. If
they are, update the paths to be relative to the managed directories.
This migration is a no-op for normal users (their paths will already be relative), but is necessary for users
who have been testing the RCs with their live databases. The paths were made absolute in the initial RC, but this
change was reverted. To smooth over the revert for our tests, we can migrate the paths back to relative.
"""
models_path = self._app_config.models_path
legacy_conf_path = self._app_config.legacy_conf_path
legacy_conf_dir = self._app_config.legacy_conf_dir
stmt = """---sql
SELECT
id,
path,
json_extract(config, '$.config_path') as config_path
FROM models;
"""
all_models = cursor.execute(stmt).fetchall()
for model_id, model_path, model_config_path in all_models:
# If the model path is inside the models directory, update it to be relative to the models directory.
if Path(model_path).is_relative_to(models_path):
new_path = Path(model_path).relative_to(models_path)
cursor.execute(
"""--sql
UPDATE models
SET config = json_set(config, '$.path', ?)
WHERE id = ?;
""",
(str(new_path), model_id),
)
# If the model has a legacy config path and it is inside the legacy conf directory, update it to be
# relative to the legacy conf directory. This also fixes up cases in which the config path was
# incorrectly relativized to the root directory. It will now be relativized to the legacy conf directory.
if model_config_path:
if Path(model_config_path).is_relative_to(legacy_conf_path):
new_config_path = Path(model_config_path).relative_to(legacy_conf_path)
elif Path(model_config_path).is_relative_to(legacy_conf_dir):
new_config_path = Path(*Path(model_config_path).parts[1:])
else:
new_config_path = None
if new_config_path:
cursor.execute(
"""--sql
UPDATE models
SET config = json_set(config, '$.config_path', ?)
WHERE id = ?;
""",
(str(new_config_path), model_id),
)
def build_migration_8(app_config: InvokeAIAppConfig) -> Migration:
"""
Build the migration from database version 7 to 8.
This migration does the following:
- Removes the `model_config` table.
- Migrates absolute model & legacy config paths to be relative to the models directory.
"""
migration_8 = Migration(
id="migration_8",
depends_on="migration_7",
from_version=7,
to_version=8,
callback=Migration8Callback(app_config),
)
return migration_8
@@ -0,0 +1,31 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration9Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._empty_session_queue(cursor)
def _empty_session_queue(self, cursor: sqlite3.Cursor) -> None:
"""Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas."""
cursor.execute("DELETE FROM session_queue;")
def build_migration_9() -> Migration:
"""
Build the migration from database version 8 to 9.
This migration does the following:
- Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas.
"""
migration_9 = Migration(
id="migration_9",
depends_on="migration_8",
from_version=8,
to_version=9,
callback=Migration9Callback(),
)
return migration_9
@@ -0,0 +1,284 @@
import sqlite3
from typing import Optional, Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator
@runtime_checkable
class MigrateCallback(Protocol):
"""
A callback that performs a migration.
Migrate callbacks are provided an open cursor to the database. They should not commit their
transaction; this is handled by the migrator.
If the callback needs to access additional dependencies, will be provided to the callback at runtime.
See :class:`Migration` for an example.
"""
def __call__(self, cursor: sqlite3.Cursor) -> None: ...
class MigrationError(RuntimeError):
"""Raised when a migration fails."""
class MigrationVersionError(ValueError):
"""Raised when a migration version is invalid."""
class Migration(BaseModel):
"""
Represents a migration for a SQLite database.
:param from_version: The legacy database version on which this migration may be run
:param to_version: The legacy database version that results from this migration
:param id: The stable migration ID. Legacy migrations default to ``migration_{to_version}``.
:param depends_on: The stable ID of the migration that must run first.
:param migrate_callback: The callback to run to perform the migration
Migrations are executed according to their stable ID dependencies. Existing legacy migrations also keep
``from_version`` and ``to_version`` so older numeric migration state can be mapped to applied migration IDs.
New graph-only migrations may omit legacy versions, but must provide an explicit ``id``.
Migration callbacks will be provided an open cursor to the database. They should not commit their
transaction; this is handled by the migrator.
It is suggested to use a class to define the migration callback and a builder function to create
the :class:`Migration`. This allows the callback to be provided with additional dependencies and
keeps things tidy, as all migration logic is self-contained.
Example:
```py
# Define the migration callback class
class Migration1Callback:
# This migration needs a logger, so we define a class that accepts a logger in its constructor.
def __init__(self, image_files: ImageFileStorageBase) -> None:
self._image_files = ImageFileStorageBase
# This dunder method allows the instance of the class to be called like a function.
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._add_with_banana_column(cursor)
self._do_something_with_images(cursor)
def _add_with_banana_column(self, cursor: sqlite3.Cursor) -> None:
\"""Adds the with_banana column to the sushi table.\"""
# Execute SQL using the cursor, taking care to *not commit* a transaction
cursor.execute('ALTER TABLE sushi ADD COLUMN with_banana BOOLEAN DEFAULT TRUE;')
def _do_something_with_images(self, cursor: sqlite3.Cursor) -> None:
\"""Does something with the image files service.\"""
self._image_files.get(...)
# Define the migration builder function. This function creates an instance of the migration callback
# class and returns a Migration.
def build_migration_1(image_files: ImageFileStorageBase) -> Migration:
\"""Builds the migration from database version 0 to 1.
Requires the image files service to...
\"""
migration_1 = Migration(
from_version=0,
to_version=1,
migrate_callback=Migration1Callback(image_files=image_files),
)
return migration_1
# Register the migration after all dependencies have been initialized
db = SqliteDatabase(db_path, logger)
migrator = SqliteMigrator(db)
migrator.register_migration(build_migration_1(image_files))
migrator.run_migrations()
```
"""
from_version: Optional[int] = Field(
default=None, ge=0, strict=True, description="The database version on which this migration may be run"
)
to_version: Optional[int] = Field(
default=None, ge=1, strict=True, description="The database version that results from this migration"
)
id: Optional[str] = Field(default=None, description="Stable migration ID")
depends_on: Optional[str] = Field(default=None, description="Stable ID of the migration dependency")
callback: MigrateCallback = Field(description="The callback to run to perform the migration")
@model_validator(mode="after")
def validate_versions_and_ids(self) -> "Migration":
"""Validates legacy versions and derives stable IDs for legacy migrations."""
has_from_version = self.from_version is not None
has_to_version = self.to_version is not None
if has_from_version != has_to_version:
raise MigrationVersionError("from_version and to_version must both be provided for legacy migrations")
if self.from_version is not None and self.to_version is not None and self.to_version != self.from_version + 1:
raise MigrationVersionError("to_version must be one greater than from_version")
if self.id is None and self.to_version is not None:
self.id = f"migration_{self.to_version}"
if self.id is None:
raise MigrationVersionError("id is required for graph-only migrations")
if self.depends_on is None and self.from_version is not None and self.from_version > 0:
self.depends_on = f"migration_{self.from_version}"
if self.depends_on == self.id:
raise MigrationVersionError("migration cannot depend on itself")
return self
def __hash__(self) -> int:
# Callables are not hashable, so we need to implement our own __hash__ function to use this class in a set.
if self.from_version is not None and self.to_version is not None:
return hash((self.from_version, self.to_version))
return hash(self.id)
@property
def sort_key(self) -> tuple[int, int, str]:
"""Deterministic sort key for runnable migrations."""
if self.to_version is None:
return (1, 0, self.id or "")
return (0, self.to_version, self.id or "")
model_config = ConfigDict(arbitrary_types_allowed=True)
class MigrationSet:
"""
A set of Migrations. Performs validation during migration registration and provides utility methods.
Migrations should be registered with `register()`. Once all are registered, `validate_dependency_graph()`
should be called to ensure that dependencies are complete and acyclic. `validate_migration_chain()` is retained for
legacy chain validation tests and compatibility checks.
"""
def __init__(self) -> None:
self._migrations: set[Migration] = set()
def register(self, migration: Migration) -> None:
"""Registers a migration."""
migration_from_already_registered = migration.from_version is not None and any(
m.from_version == migration.from_version for m in self._migrations if m.from_version is not None
)
migration_to_already_registered = migration.to_version is not None and any(
m.to_version == migration.to_version for m in self._migrations if m.to_version is not None
)
if migration_from_already_registered or migration_to_already_registered:
raise MigrationVersionError("Migration with from_version or to_version already registered")
migration_id_already_registered = any(m.id == migration.id for m in self._migrations)
if migration_id_already_registered:
raise MigrationVersionError("Migration with id already registered")
self._migrations.add(migration)
def get(self, from_version: int) -> Optional[Migration]:
"""Gets the migration that may be run on the given database version."""
# register() ensures that there is only one migration with a given from_version, so this is safe.
return next((m for m in self._migrations if m.from_version == from_version), None)
def validate_migration_chain(self) -> None:
"""
Validates that the migrations form a single chain of migrations from version 0 to the latest version,
Raises a MigrationError if there is a problem.
"""
if self.count == 0:
return
if self.latest_version == 0:
return
next_migration = self.get(from_version=0)
if next_migration is None:
raise MigrationError("Migration chain is fragmented")
touched_count = 1
while next_migration is not None:
next_migration = self.get(next_migration.to_version)
if next_migration is not None:
touched_count += 1
if touched_count != self.count:
raise MigrationError("Migration chain is fragmented")
def validate_dependency_graph(self) -> None:
"""Validates migration ID dependencies."""
migration_ids = {migration.id for migration in self._migrations}
migrations_by_id = self.migrations_by_id
for migration in self._migrations:
if migration.depends_on is not None and migration.depends_on not in migration_ids:
raise MigrationError(
f"Migration '{migration.id}' depends on unknown migration '{migration.depends_on}'"
)
if migration.to_version is not None and migration.depends_on is not None:
dependency = migrations_by_id[migration.depends_on]
if dependency.to_version is None:
raise MigrationError(
f"Legacy migration '{migration.id}' cannot depend on graph-only migration '{dependency.id}'"
)
visiting: set[str] = set()
visited: set[str] = set()
def visit(migration: Migration) -> None:
migration_id = migration.id
if migration_id is None:
raise MigrationError("Migration is missing id")
if migration_id in visited:
return
if migration_id in visiting:
raise MigrationError("Migration dependency graph contains a cycle")
visiting.add(migration_id)
if migration.depends_on is not None:
visit(migrations_by_id[migration.depends_on])
visiting.remove(migration_id)
visited.add(migration_id)
for migration in self._migrations:
visit(migration)
def get_migration_plan(self, applied_migration_ids: set[str]) -> list[Migration]:
"""Gets a deterministic migration plan from the set of applied migration IDs."""
self.validate_dependency_graph()
known_migration_ids = set(self.migrations_by_id)
unknown_applied_ids = applied_migration_ids - known_migration_ids
if unknown_applied_ids:
unknown_ids = ", ".join(sorted(unknown_applied_ids))
raise MigrationError(f"Database contains unknown applied migration IDs: {unknown_ids}")
plan: list[Migration] = []
planned_or_applied_ids = set(applied_migration_ids)
remaining = {
migration.id: migration for migration in self._migrations if migration.id not in applied_migration_ids
}
while remaining:
runnable = sorted(
(
migration
for migration in remaining.values()
if migration.depends_on is None or migration.depends_on in planned_or_applied_ids
),
key=lambda migration: migration.sort_key,
)
if not runnable:
raise MigrationError("Migration dependency graph cannot be resolved")
migration = runnable[0]
plan.append(migration)
planned_or_applied_ids.add(migration.id or "")
del remaining[migration.id]
return plan
@property
def count(self) -> int:
"""The count of registered migrations."""
return len(self._migrations)
@property
def latest_version(self) -> int:
"""Gets latest to_version among registered migrations. Returns 0 if there are no migrations registered."""
if self.count == 0:
return 0
legacy_migrations = [migration for migration in self._migrations if migration.to_version is not None]
if len(legacy_migrations) == 0:
return 0
latest_version = sorted(legacy_migrations, key=lambda m: m.to_version or 0)[-1].to_version
return latest_version or 0
@property
def migrations(self) -> tuple[Migration, ...]:
return tuple(sorted(self._migrations, key=lambda migration: migration.sort_key))
@property
def migrations_by_id(self) -> dict[str, Migration]:
return {migration.id or "": migration for migration in self._migrations}
@@ -0,0 +1,302 @@
import sqlite3
from contextlib import closing
from datetime import datetime
from pathlib import Path
from typing import Optional
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration, MigrationError, MigrationSet
class SqliteMigrator:
"""
Manages migrations for a SQLite database.
:param db: The instance of :class:`SqliteDatabase` to migrate.
Migrations should be registered with :meth:`register_migration`, either directly or via the migration loader.
They are planned by stable migration ID dependencies and recorded in the ``applied_migrations`` table.
Legacy numeric versions are still written for migrations that define ``to_version``.
Each migration is run in a transaction. If a migration fails, the transaction is rolled back.
Example Usage:
```py
db = SqliteDatabase(db_path="my_db.db", logger=logger)
migrator = SqliteMigrator(db=db)
migrator.register_migration(build_migration_1())
migrator.register_migration(build_migration_2())
migrator.run_migrations()
```
"""
backup_path: Optional[Path] = None
def __init__(self, db: SqliteDatabase) -> None:
self._db = db
self._logger = db._logger
self._migration_set = MigrationSet()
self._backup_path: Optional[Path] = None
def register_migration(self, migration: Migration) -> None:
"""Registers a migration."""
self._migration_set.register(migration)
self._logger.debug(f"Registered migration {migration.from_version} -> {migration.to_version}")
def run_migrations(self) -> bool:
"""Migrates the database to the latest version."""
# This throws if there is a problem.
self._migration_set.validate_dependency_graph()
cursor = self._db._conn.cursor()
self._validate_existing_applied_migrations(cursor=cursor)
self._create_migrations_table(cursor=cursor)
self._validate_existing_legacy_migrations(cursor=cursor)
if self._needs_applied_migrations_bootstrap(cursor=cursor):
self._backup_db()
self._create_applied_migrations_table(cursor=cursor)
self._validate_existing_applied_legacy_migrations(cursor=cursor)
self._bootstrap_applied_migrations_from_legacy_versions(cursor=cursor)
if self._migration_set.count == 0:
self._logger.debug("No migrations registered")
return False
applied_migration_ids = self._get_applied_migration_ids(cursor=cursor)
migration_plan = self._migration_set.get_migration_plan(applied_migration_ids=applied_migration_ids)
if len(migration_plan) == 0:
self._logger.debug("Database is up to date, no migrations to run")
return False
self._logger.info("Database update needed")
self._backup_db()
for migration in migration_plan:
self._run_migration(migration)
self._logger.info("Database updated successfully")
return True
def _backup_db(self) -> None:
"""Makes a backup of the db if it is a file db and a backup has not already been made."""
if self._backup_path is not None:
return
if self._db._db_path is not None:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
self._backup_path = self._db._db_path.parent / f"{self._db._db_path.stem}_backup_{timestamp}.db"
self._logger.info(f"Backing up database to {str(self._backup_path)}")
with closing(sqlite3.connect(self._backup_path)) as backup_conn:
self._db._conn.backup(backup_conn)
else:
self._logger.info("Using in-memory database, no backup needed")
def _run_migration(self, migration: Migration) -> None:
"""Runs a single migration."""
try:
# Using sqlite3.Connection as a context manager commits a the transaction on exit, or rolls it back if an
# exception is raised.
with self._db._conn as conn:
cursor = conn.cursor()
self._create_applied_migrations_table(cursor)
if migration.from_version is not None and self._get_current_version(cursor) != migration.from_version:
raise MigrationError(
f"Database is at version {self._get_current_version(cursor)}, expected {migration.from_version}"
)
self._logger.debug(f"Running migration '{migration.id}'")
# Run the actual migration
migration.callback(cursor)
if migration.to_version is not None:
cursor.execute("INSERT INTO migrations (version) VALUES (?);", (migration.to_version,))
cursor.execute(
"INSERT INTO applied_migrations (migration_id, legacy_version) VALUES (?, ?);",
(migration.id, migration.to_version),
)
self._logger.debug(f"Successfully ran migration '{migration.id}'")
# We want to catch *any* error, mirroring the behaviour of the sqlite3 module.
except Exception as e:
# The connection context manager has already rolled back the migration, so we don't need to do anything.
msg = f"Error running migration '{migration.id}': {e}"
self._logger.error(msg)
raise MigrationError(msg) from e
def _create_migrations_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the migrations table for the database, if one does not already exist."""
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
if cursor.fetchone() is not None:
return
cursor.execute(
"""--sql
CREATE TABLE migrations (
version INTEGER PRIMARY KEY,
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
cursor.execute("INSERT INTO migrations (version) VALUES (0);")
cursor.connection.commit()
self._logger.debug("Created migrations table")
except sqlite3.Error as e:
msg = f"Problem creating migrations table: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _create_applied_migrations_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the applied migrations table for stable migration IDs."""
try:
cursor.execute(
"""--sql
CREATE TABLE IF NOT EXISTS applied_migrations (
migration_id TEXT PRIMARY KEY,
legacy_version INTEGER UNIQUE,
migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
)
except sqlite3.Error as e:
msg = f"Problem creating applied_migrations table: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _bootstrap_applied_migrations_from_legacy_versions(self, cursor: sqlite3.Cursor) -> None:
"""Backfills applied migration IDs from legacy numeric migration rows."""
try:
cursor.execute("SELECT version FROM migrations WHERE version > 0 ORDER BY version;")
legacy_versions = [row[0] for row in cursor.fetchall()]
registered_migration_ids = self._migration_set.migrations_by_id
for legacy_version in legacy_versions:
migration_id = f"migration_{legacy_version}"
if migration_id not in registered_migration_ids:
cursor.connection.rollback()
raise MigrationError(f"Database contains unknown legacy migration version: {legacy_version}")
cursor.execute(
"SELECT legacy_version FROM applied_migrations WHERE migration_id = ?;",
(migration_id,),
)
migration_row = cursor.fetchone()
if migration_row is not None and migration_row[0] != legacy_version:
cursor.connection.rollback()
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is recorded with legacy version {migration_row[0]}, "
f"expected {legacy_version}"
)
cursor.execute(
"SELECT migration_id FROM applied_migrations WHERE legacy_version = ?;",
(legacy_version,),
)
legacy_row = cursor.fetchone()
if legacy_row is not None and legacy_row[0] != migration_id:
cursor.connection.rollback()
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"legacy version {legacy_version} is recorded for {legacy_row[0]}, expected {migration_id}"
)
cursor.execute(
"INSERT OR IGNORE INTO applied_migrations (migration_id, legacy_version) VALUES (?, ?);",
(migration_id, legacy_version),
)
cursor.connection.commit()
except sqlite3.Error as e:
msg = f"Problem bootstrapping applied migrations: {e}"
self._logger.error(msg)
cursor.connection.rollback()
raise MigrationError(msg) from e
def _validate_existing_applied_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates existing applied migration IDs before creating or mutating migrator metadata."""
applied_migration_ids = self._get_applied_migration_ids(cursor=cursor)
if len(applied_migration_ids) == 0:
return
known_migration_ids = set(self._migration_set.migrations_by_id)
unknown_applied_ids = applied_migration_ids - known_migration_ids
if unknown_applied_ids:
unknown_ids = ", ".join(sorted(unknown_applied_ids))
raise MigrationError(f"Database contains unknown applied migration IDs: {unknown_ids}")
def _validate_existing_legacy_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates existing legacy migration versions before creating applied migration metadata."""
try:
cursor.execute("SELECT version FROM migrations WHERE version > 0 ORDER BY version;")
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return
raise
registered_migration_ids = self._migration_set.migrations_by_id
for row in cursor.fetchall():
legacy_version = row[0]
migration_id = f"migration_{legacy_version}"
if migration_id not in registered_migration_ids:
raise MigrationError(f"Database contains unknown legacy migration version: {legacy_version}")
def _validate_existing_applied_legacy_migrations(self, cursor: sqlite3.Cursor) -> None:
"""Validates applied IDs for legacy migrations against legacy numeric rows."""
registered_migrations = self._migration_set.migrations_by_id
cursor.execute("SELECT migration_id, legacy_version FROM applied_migrations;")
applied_rows = cursor.fetchall()
cursor.execute("SELECT version FROM migrations WHERE version > 0;")
legacy_versions = {row[0] for row in cursor.fetchall()}
for row in applied_rows:
migration_id = row[0]
legacy_version = row[1]
migration = registered_migrations[migration_id]
if migration.to_version is None:
continue
if legacy_version != migration.to_version:
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is recorded with legacy version {legacy_version}, "
f"expected {migration.to_version}"
)
if legacy_version not in legacy_versions:
raise MigrationError(
"Database contains inconsistent applied migration state: "
f"{migration_id} is applied, but legacy version {legacy_version} is missing"
)
def _needs_applied_migrations_bootstrap(self, cursor: sqlite3.Cursor) -> bool:
"""Checks whether legacy numeric rows need to be written to applied_migrations."""
cursor.execute("SELECT version FROM migrations WHERE version > 0;")
legacy_versions = {row[0] for row in cursor.fetchall()}
if len(legacy_versions) == 0:
return False
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='applied_migrations';")
if cursor.fetchone() is None:
return True
cursor.execute("SELECT legacy_version FROM applied_migrations WHERE legacy_version IS NOT NULL;")
applied_legacy_versions = {row[0] for row in cursor.fetchall()}
return not legacy_versions.issubset(applied_legacy_versions)
@classmethod
def _get_applied_migration_ids(cls, cursor: sqlite3.Cursor) -> set[str]:
"""Gets applied stable migration IDs."""
try:
cursor.execute("SELECT migration_id FROM applied_migrations;")
return {row[0] for row in cursor.fetchall()}
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return set()
raise
@classmethod
def _get_current_version(cls, cursor: sqlite3.Cursor) -> int:
"""Gets the current version of the database, or 0 if the migrations table does not exist."""
try:
cursor.execute("SELECT MAX(version) FROM migrations;")
version: int = cursor.fetchone()[0]
if version is None:
return 0
return version
except sqlite3.OperationalError as e:
if "no such table" in str(e):
return 0
raise
@@ -0,0 +1,229 @@
from collections.abc import Mapping
from copy import deepcopy
from typing import Any, get_args, get_origin
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from invokeai.app.invocations.baseinvocation import InvocationRegistry
from invokeai.app.invocations.call_saved_workflow import parse_call_saved_workflow_dynamic_input
from invokeai.app.invocations.fields import ImageField
from invokeai.app.services.session_processor.workflow_call_batch import build_child_workflow_sessions
from invokeai.app.services.session_queue.session_queue_common import TooManySessionsError
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, WorkflowCallFrame
from invokeai.app.services.shared.workflow_call_compatibility_common import (
WorkflowCallCompatibility,
WorkflowCallCompatibilityReason,
)
from invokeai.app.services.shared.workflow_graph_builder import (
InvalidWorkflowInputError,
UnsupportedWorkflowNodeError,
get_exposed_workflow_input_names,
)
def _count_workflow_return_nodes(workflow: dict[str, Any]) -> int:
workflow_return_count = 0
for node in workflow.get("nodes", []):
if not isinstance(node, dict) or node.get("type") != "invocation":
continue
data = node.get("data")
if isinstance(data, dict) and data.get("type") == "workflow_return":
workflow_return_count += 1
return workflow_return_count
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping)
def _build_placeholder_model(annotation: type[BaseModel]) -> Any:
values: dict[str, Any] = {}
for field_name, field_info in annotation.model_fields.items():
if field_info.default is not PydanticUndefined:
values[field_name] = deepcopy(field_info.default)
continue
if field_info.default_factory is not None:
values[field_name] = field_info.default_factory()
continue
placeholder = _get_placeholder_for_annotation(field_info.annotation)
if placeholder is None:
return None
values[field_name] = placeholder
return annotation.model_construct(**values)
def _get_placeholder_for_annotation(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin is not None:
if origin is list:
return []
if origin is dict:
return {}
if origin is tuple:
return []
if origin is set:
return []
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if args:
return _get_placeholder_for_annotation(args[0])
return None
if annotation is Any:
return {}
if annotation is str:
return ""
if annotation is int:
return 0
if annotation is float:
return 0.0
if annotation is bool:
return False
if annotation is ImageField:
return ImageField(image_name="compatibility-placeholder")
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return _build_placeholder_model(annotation)
return None
def _build_compatibility_workflow_inputs(workflow: dict[str, Any]) -> dict[str, Any]:
workflow_inputs: dict[str, Any] = {}
workflow_nodes = workflow.get("nodes", [])
if not isinstance(workflow_nodes, list):
return workflow_inputs
nodes_by_id = {
node.get("id"): node
for node in workflow_nodes
if _is_mapping(node) and isinstance(node.get("id"), str) and _is_mapping(node.get("data"))
}
for input_name in get_exposed_workflow_input_names(workflow):
node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
node = nodes_by_id.get(node_id)
if not _is_mapping(node):
continue
node_data = node.get("data")
if not _is_mapping(node_data):
continue
node_type = node_data.get("type")
if not isinstance(node_type, str):
continue
invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
if invocation_class is None:
continue
field_info = invocation_class.model_fields.get(field_name)
if field_info is None:
continue
if field_info.default is not PydanticUndefined:
workflow_inputs[input_name] = deepcopy(field_info.default)
continue
if field_info.default_factory is not None:
workflow_inputs[input_name] = field_info.default_factory()
continue
placeholder = _get_placeholder_for_annotation(field_info.annotation)
if placeholder is not None:
workflow_inputs[input_name] = placeholder
return workflow_inputs
def _is_unsupported_batch_input_message(message: str) -> bool:
return any(
marker in message
for marker in (
"batch child workflow",
"batch group",
"batch input",
"batch inputs",
"batch node",
"batch-special child workflow nodes",
"connected batch",
"generator-backed batch",
)
)
def get_workflow_call_compatibility(
*,
workflow: dict[str, Any],
workflow_id: str,
services: Any,
user_id: str | None,
maximum_children: int,
resolve_generator_items: bool = True,
) -> WorkflowCallCompatibility:
workflow_return_count = _count_workflow_return_nodes(workflow)
if workflow_return_count == 0:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.MissingWorkflowReturn,
message="The workflow must contain exactly one workflow_return node.",
)
if workflow_return_count > 1:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.MultipleWorkflowReturn,
message="The workflow must not contain more than one workflow_return node.",
)
try:
workflow_inputs = _build_compatibility_workflow_inputs(workflow)
build_child_workflow_sessions(
parent_session=GraphExecutionState(graph=Graph()),
workflow=workflow,
workflow_inputs=workflow_inputs,
call_frame=WorkflowCallFrame(
prepared_call_node_id="compatibility-call",
source_call_node_id="compatibility-call",
workflow_id=workflow_id,
depth=1,
),
maximum_children=maximum_children,
services=services,
user_id=user_id,
resolve_generator_items=resolve_generator_items,
)
except InvalidWorkflowInputError as e:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.InvalidInputs,
message=str(e),
)
except UnsupportedWorkflowNodeError as e:
message = str(e)
reason = WorkflowCallCompatibilityReason.UnsupportedNode
if _is_unsupported_batch_input_message(message):
reason = WorkflowCallCompatibilityReason.UnsupportedBatchInput
elif "exactly one workflow_return" in message and workflow_return_count == 0:
reason = WorkflowCallCompatibilityReason.MissingWorkflowReturn
elif "exactly one workflow_return" in message:
reason = WorkflowCallCompatibilityReason.InvalidGraph
return WorkflowCallCompatibility(
is_callable=False,
reason=reason,
message=message,
)
except TooManySessionsError as e:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.ExceedsCapacity,
message=str(e),
)
except ValueError as e:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.InvalidGraph,
message=str(e),
)
except Exception as e:
return WorkflowCallCompatibility(
is_callable=False,
reason=WorkflowCallCompatibilityReason.Unknown,
message=str(e),
)
return WorkflowCallCompatibility(
is_callable=True,
reason=WorkflowCallCompatibilityReason.Ok,
)
@@ -0,0 +1,21 @@
from enum import Enum
from pydantic import BaseModel, Field
class WorkflowCallCompatibilityReason(str, Enum):
Ok = "ok"
MissingWorkflowReturn = "missing_workflow_return"
MultipleWorkflowReturn = "multiple_workflow_return"
UnsupportedNode = "unsupported_node"
UnsupportedBatchInput = "unsupported_batch_input"
InvalidGraph = "invalid_graph"
InvalidInputs = "invalid_inputs"
ExceedsCapacity = "exceeds_capacity"
Unknown = "unknown"
class WorkflowCallCompatibility(BaseModel):
is_callable: bool = Field(description="Whether the workflow can currently be executed by call_saved_workflow.")
reason: WorkflowCallCompatibilityReason = Field(description="Structured compatibility result.")
message: str | None = Field(default=None, description="Human-readable compatibility detail when unavailable.")
@@ -0,0 +1,375 @@
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any
from invokeai.app.invocations.baseinvocation import Classification, InvocationRegistry
from invokeai.app.invocations.call_saved_workflow import (
CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX,
parse_call_saved_workflow_dynamic_input,
)
from invokeai.app.services.shared.graph import Edge, EdgeConnection, Graph
CONNECTOR_INPUT_HANDLE = "in"
CONNECTOR_OUTPUT_HANDLE = "out"
class UnsupportedWorkflowNodeError(ValueError):
pass
class InvalidWorkflowInputError(ValueError):
pass
def _is_mapping(value: Any) -> bool:
return isinstance(value, Mapping)
def _is_invocation_node(node: Any) -> bool:
return _is_mapping(node) and node.get("type") == "invocation" and _is_mapping(node.get("data"))
def _is_connector_node(node: Any) -> bool:
return _is_mapping(node) and node.get("type") == "connector"
def _build_dynamic_input_name(node_id: str, field_name: str) -> str:
return f"{CALL_SAVED_WORKFLOW_DYNAMIC_FIELD_PREFIX}{node_id}::{field_name}"
def _get_form_elements(workflow: Mapping[str, Any]) -> tuple[Mapping[str, Any], str | None]:
form = workflow.get("form")
if not _is_mapping(form):
return {}, None
elements = form.get("elements")
root_element_id = form.get("rootElementId")
if not _is_mapping(elements) or not isinstance(root_element_id, str):
return {}, None
return elements, root_element_id
def _collect_exposed_inputs_from_form(workflow: Mapping[str, Any]) -> set[str]:
elements, root_element_id = _get_form_elements(workflow)
if not elements or root_element_id is None:
return set()
exposed_inputs: set[str] = set()
stack = [root_element_id]
visited: set[str] = set()
while stack:
element_id = stack.pop()
if element_id in visited:
continue
visited.add(element_id)
element = elements.get(element_id)
if not _is_mapping(element):
continue
if element.get("type") == "node-field":
data = element.get("data")
if _is_mapping(data):
field_identifier = data.get("fieldIdentifier")
if _is_mapping(field_identifier):
node_id = field_identifier.get("nodeId")
field_name = field_identifier.get("fieldName")
if isinstance(node_id, str) and isinstance(field_name, str):
exposed_inputs.add(_build_dynamic_input_name(node_id, field_name))
data = element.get("data")
if _is_mapping(data):
children = data.get("children")
if isinstance(children, Sequence):
for child_id in reversed(children):
if isinstance(child_id, str):
stack.append(child_id)
return exposed_inputs
def get_exposed_workflow_input_names(workflow: Mapping[str, Any]) -> set[str]:
exposed_inputs = _collect_exposed_inputs_from_form(workflow)
if exposed_inputs:
return exposed_inputs
workflow_exposed_fields = workflow.get("exposedFields", [])
if not isinstance(workflow_exposed_fields, Sequence):
return set()
fallback_inputs: set[str] = set()
for field in workflow_exposed_fields:
if not _is_mapping(field):
continue
node_id = field.get("nodeId")
field_name = field.get("fieldName")
if isinstance(node_id, str) and isinstance(field_name, str):
fallback_inputs.add(_build_dynamic_input_name(node_id, field_name))
return fallback_inputs
def apply_workflow_inputs_to_workflow(workflow: MutableMapping[str, Any], workflow_inputs: Mapping[str, Any]) -> None:
if not workflow_inputs:
return
allowed_inputs = get_exposed_workflow_input_names(workflow)
for input_name, value in workflow_inputs.items():
if input_name not in allowed_inputs:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' is not exposed by the selected workflow"
)
node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
workflow_nodes = workflow.get("nodes", [])
if not isinstance(workflow_nodes, list):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
matching_node = next(
(
node
for node in workflow_nodes
if _is_mapping(node)
and _is_mapping(node.get("data"))
and node.get("id") == node_id
and node["data"].get("id") == node_id
),
None,
)
if matching_node is None:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
matching_node_data = matching_node["data"]
node_type = matching_node_data.get("type")
if not isinstance(node_type, str):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow node '{node_id}'"
)
invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
if invocation_class is None or field_name not in invocation_class.model_fields:
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets missing child workflow field '{field_name}'"
)
inputs = matching_node_data.setdefault("inputs", {})
if not _is_mapping(inputs):
raise InvalidWorkflowInputError(
f"call_saved_workflow input '{input_name}' targets invalid child workflow inputs on '{node_id}'"
)
inputs[field_name] = {"value": value}
def apply_workflow_inputs_to_graph(
graph: Graph, workflow: Mapping[str, Any], workflow_inputs: Mapping[str, Any]
) -> None:
if not workflow_inputs:
return
mutable_workflow = dict(workflow)
apply_workflow_inputs_to_workflow(mutable_workflow, workflow_inputs)
for input_name, value in workflow_inputs.items():
node_id, field_name = parse_call_saved_workflow_dynamic_input(input_name)
node = graph.nodes.get(node_id)
if node is None:
continue
setattr(node, field_name, value)
def _raise_if_unsupported_invocation_type(node_type: str, node_id: str) -> None:
invocation_class = InvocationRegistry.get_invocation_for_type(node_type)
if invocation_class is None:
return
if (
invocation_class.UIConfig.category == "batch"
and invocation_class.UIConfig.classification == Classification.Special
and not node_type.endswith("_generator")
):
raise UnsupportedWorkflowNodeError(
f"call_saved_workflow does not yet support batch-special child workflow nodes such as "
f"'{node_type}' (node '{node_id}')"
)
def _validate_callable_workflow_nodes(workflow_nodes: Sequence[Any]) -> None:
workflow_return_node_ids: list[str] = []
for node in workflow_nodes:
if not _is_invocation_node(node):
continue
data = node["data"]
node_id = data.get("id")
node_type = data.get("type")
if not isinstance(node_id, str) or not isinstance(node_type, str):
continue
_raise_if_unsupported_invocation_type(node_type, node_id)
if node_type == "workflow_return":
workflow_return_node_ids.append(node_id)
if len(workflow_return_node_ids) != 1:
raise UnsupportedWorkflowNodeError(
"call_saved_workflow requires the selected workflow to contain exactly one workflow_return node"
)
def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]:
return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"]
def _get_connector_input_edge(
connector_id: str, workflow_edges: Sequence[Mapping[str, Any]]
) -> Mapping[str, Any] | None:
return next(
(
edge
for edge in workflow_edges
if edge.get("target") == connector_id and edge.get("targetHandle") == CONNECTOR_INPUT_HANDLE
),
None,
)
def _resolve_connector_source(
connector_id: str, workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]]
) -> tuple[str, str] | None:
visited: set[str] = set()
def resolve(node_id: str) -> tuple[str, str] | None:
if node_id in visited:
return None
visited.add(node_id)
incoming_edge = _get_connector_input_edge(node_id, workflow_edges)
if incoming_edge is None:
return None
source_id = incoming_edge.get("source")
source_handle = incoming_edge.get("sourceHandle")
if not isinstance(source_id, str) or not isinstance(source_handle, str):
return None
source_node = workflow_nodes.get(source_id)
if source_node is None:
return None
if _is_invocation_node(source_node):
return (source_id, source_handle)
if _is_connector_node(source_node):
return resolve(source_id)
return None
return resolve(connector_id)
def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph:
workflow_nodes_raw = workflow.get("nodes", [])
workflow_edges_raw = workflow.get("edges", [])
_validate_callable_workflow_nodes(workflow_nodes_raw if isinstance(workflow_nodes_raw, Sequence) else [])
workflow_nodes = {
node["id"]: node for node in workflow_nodes_raw if _is_mapping(node) and isinstance(node.get("id"), str)
}
default_edges = _get_default_edges(workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else [])
parsed_nodes: dict[str, dict[str, Any]] = {}
for node in workflow_nodes.values():
if not _is_invocation_node(node):
continue
data = node["data"]
node_id = data.get("id")
node_type = data.get("type")
if not isinstance(node_id, str) or not isinstance(node_type, str):
continue
graph_node: dict[str, Any] = {
"id": node_id,
"type": node_type,
"use_cache": data.get("useCache", False),
"is_intermediate": data.get("isIntermediate", False),
}
inputs = data.get("inputs", {})
if _is_mapping(inputs):
for field_name, field_value in inputs.items():
if not isinstance(field_name, str) or not _is_mapping(field_value):
continue
# Saved workflows may include input metadata for unfilled optional fields without a "value".
# Omit those fields so invocation defaults are applied instead of forcing None.
if "value" in field_value:
# The frontend board picker may persist sentinel strings; graph nodes model both
# automatic and no-board selection as the board field default, None.
if field_name == "board" and field_value["value"] in ("auto", "none"):
continue
graph_node[field_name] = field_value["value"]
parsed_nodes[node_id] = graph_node
parsed_edges: list[dict[str, dict[str, str]]] = []
seen_edges: set[tuple[str, str, str, str]] = set()
for edge in default_edges:
source_id = edge.get("source")
target_id = edge.get("target")
source_handle = edge.get("sourceHandle")
target_handle = edge.get("targetHandle")
if not all(isinstance(v, str) for v in (source_id, target_id, source_handle, target_handle)):
continue
target_node = workflow_nodes.get(target_id)
if not _is_invocation_node(target_node):
continue
source_node = workflow_nodes.get(source_id)
resolved_source: tuple[str, str] | None = None
if _is_invocation_node(source_node):
resolved_source = (source_id, source_handle)
elif _is_connector_node(source_node):
resolved_source = _resolve_connector_source(source_id, workflow_nodes, default_edges)
if resolved_source is None:
continue
resolved_source_id, resolved_source_handle = resolved_source
edge_key = (resolved_source_id, resolved_source_handle, target_id, target_handle)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
parsed_edges.append(
{
"source": {
"node_id": resolved_source_id,
"field": resolved_source_handle,
},
"destination": {
"node_id": target_id,
"field": target_handle,
},
}
)
for edge in parsed_edges:
destination_node_id = edge["destination"]["node_id"]
destination_field = edge["destination"]["field"]
parsed_nodes[destination_node_id].pop(destination_field, None)
return Graph.model_validate(
{
"nodes": parsed_nodes,
"edges": [
Edge(
source=EdgeConnection(**edge["source"]),
destination=EdgeConnection(**edge["destination"]),
)
for edge in parsed_edges
],
}
)