From 5f1d22b3b9c63526d2669b439cd133e74c13e2d1 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 02:02:51 -0600 Subject: [PATCH 01/16] feat(boards): store board roles and key projects by id alone Replace board visibility, is_public and shared_boards with board_members and general_access, read through the query layer. Key projects by project_id across accounts, re-keying any shared id, and add per-member project_workspaces with a document schema floor. Clear an account's board project memberships when the account is deleted. --- .../Multi-User Mode/admin-guide.mdx | 19 +- .../image_index/image_index_records_base.py | 2 +- .../shared/database/queries/board_access.py | 17 +- .../shared/database/queries/boards.py | 49 ++-- .../services/shared/database/schema/boards.py | 29 ++- .../database/schema/media_references.py | 2 +- .../shared/database/schema/projects.py | 30 ++- ...2026_10_08_add_board_project_membership.py | 2 +- .../migration_2026_10_10_board_roles.py | 134 +++++++++++ ...migration_2026_10_10_global_project_ids.py | 214 +++++++++++++++++ ...migration_2026_10_10_project_workspaces.py | 63 +++++ .../sqlite_migrator/sqlite_migrator_impl.py | 6 +- invokeai/app/services/users/users_default.py | 2 + tests/app/routers/test_boards_projects.py | 4 +- .../routers/test_multiuser_authorization.py | 4 +- .../app/services/boards/test_board_storage.py | 38 ++- .../image_index/test_image_index_records.py | 14 +- .../image_records/test_image_records.py | 4 +- .../project_records/test_project_records.py | 52 +++-- .../services/shared/database/test_schema.py | 7 +- .../test_migration_2026_10_10_board_roles.py | 153 ++++++++++++ ...migration_2026_10_10_global_project_ids.py | 220 ++++++++++++++++++ ...migration_2026_10_10_project_workspaces.py | 79 +++++++ tests/app/services/users/test_user_service.py | 19 ++ 24 files changed, 1068 insertions(+), 95 deletions(-) create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py create mode 100644 tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_board_roles.py create mode 100644 tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_global_project_ids.py create mode 100644 tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_project_workspaces.py diff --git a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx index c9894b15405..526b0180699 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/admin-guide.mdx @@ -167,6 +167,20 @@ invokeai-web A utility to migrate legacy "system"-owned assets to specific user accounts will be available in a future release. Until then, administrators can access and manage all legacy content. ::: +### Upgrading to 7.0 + +Upgrading converts board sharing and projects in place. The upgrade logs a warning for anything it +had to change beyond that: + +- **A project id held by more than one account.** Project ids are now unique across accounts. The + oldest project keeps its id; each other one gets a new id, and the log names the account's id and + the new project id. The project's document, boards, media, queue items and the account's open + projects follow it. Unsaved changes a browser was still holding for that project under its old id + are lost. +- **Boards in a project that no longer exists.** A board can name a project its owner no longer has, + for example a board of an account deleted before the upgrade. The upgrade moves such boards out of + the project and logs how many there were. + ## User Management ### Creating Users @@ -220,8 +234,9 @@ If no arguments are provided, the script will run in interactive mode. ``` :::danger[Data Loss] -Deleting a user removes the user record and cascades to their sessions, board shares, sent -invitations, and per-user client state. It does **not** delete the boards, images, videos, +Deleting a user removes the user record and cascades to their sessions, board memberships, sent +invitations, projects, and per-user client state. Their boards leave the deleted projects and stay +in no project. It does **not** delete the boards, images, videos, workflows, queue items, or style presets they created — those rows remain in the database, owned by a user_id that no longer exists, and will not appear in other (non-admin) users' galleries. Administrators keep gallery visibility of the orphaned images and videos and can review or delete diff --git a/invokeai/app/services/image_index/image_index_records_base.py b/invokeai/app/services/image_index/image_index_records_base.py index 24cceb5238d..b5ce134960d 100644 --- a/invokeai/app/services/image_index/image_index_records_base.py +++ b/invokeai/app/services/image_index/image_index_records_base.py @@ -93,7 +93,7 @@ def list_accessible_embedded_items(self, user_id: str | None, model_id: str) -> A user can access their own unboarded items, items on boards they own, items on shared or public boards, and items on boards individually shared with them - (shared_boards). Pass user_id=None for the admin scope. + (board members). Pass user_id=None for the admin scope. Items on an archived board are excluded from every scope, including the admin one, matching the gallery's "all" listing. So the admin scope is every embedded item that diff --git a/invokeai/app/services/shared/database/queries/board_access.py b/invokeai/app/services/shared/database/queries/board_access.py index 42271800b69..5e2e15b9788 100644 --- a/invokeai/app/services/shared/database/queries/board_access.py +++ b/invokeai/app/services/shared/database/queries/board_access.py @@ -1,17 +1,12 @@ """Which boards an account may read: one rule for every query that lists boards or media through them.""" -from sqlalchemy import ColumnElement, exists, literal, or_ +from sqlalchemy import ColumnElement, exists, or_ -from invokeai.app.services.board_records.board_records_common import BoardVisibility -from invokeai.app.services.shared.database.schema.boards import boards, shared_boards - -_SHARED_VISIBILITIES = (literal(BoardVisibility.Shared.value), literal(BoardVisibility.Public.value)) +from invokeai.app.services.shared.database.schema.boards import board_members, boards def readable_board(user_id: ColumnElement[str]) -> ColumnElement[bool]: - """Boards an account that is not an administrator may read: its own, those shared or public, and those shared - with it. Archived boards are not excluded here: listings decide that themselves.""" - shared_with_account = exists().where( - shared_boards.c.board_id == boards.c.board_id, shared_boards.c.user_id == user_id - ) - return or_(boards.c.user_id == user_id, boards.c.board_visibility.in_(_SHARED_VISIBILITIES), shared_with_account) + """Boards an account that is not an administrator may read: its own, those every account gets a role on, and + those it is a member of. Archived boards are not excluded here: listings decide that themselves.""" + member = exists().where(board_members.c.board_id == boards.c.board_id, board_members.c.user_id == user_id) + return or_(boards.c.user_id == user_id, boards.c.general_access != "restricted", member) diff --git a/invokeai/app/services/shared/database/queries/boards.py b/invokeai/app/services/shared/database/queries/boards.py index 588fd9b00ea..9fb99caf9eb 100644 --- a/invokeai/app/services/shared/database/queries/boards.py +++ b/invokeai/app/services/shared/database/queries/boards.py @@ -32,10 +32,23 @@ from invokeai.app.services.shared.database.dialect import CaseInsensitiveOrder from invokeai.app.services.shared.database.queries.base import IN_CHUNK, QueryModule, locking, mapped, read, write from invokeai.app.services.shared.database.queries.board_access import readable_board -from invokeai.app.services.shared.database.schema.boards import boards, shared_boards +from invokeai.app.services.shared.database.schema.boards import board_members, boards from invokeai.app.services.shared.database.schema.projects import projects from invokeai.app.services.shared.pagination import SQLiteDirection +# Board visibility as the services still see it, read from and written to general access: private is restricted, +# shared is viewer, public is contributor. +_GENERAL_ACCESS_BY_VISIBILITY = { + BoardVisibility.Private: "restricted", + BoardVisibility.Shared: "viewer", + BoardVisibility.Public: "contributor", +} +_VISIBILITY = case( + (boards.c.general_access == "viewer", BoardVisibility.Shared.value), + (boards.c.general_access == "contributor", BoardVisibility.Public.value), + else_=BoardVisibility.Private.value, +).label("board_visibility") + _BOARD_COLUMNS = ( boards.c.board_id, boards.c.board_name, @@ -45,7 +58,7 @@ boards.c.deleted_at, boards.c.cover_image_name, boards.c.archived, - boards.c.board_visibility, + _VISIBILITY, boards.c.project_id, ) _CLAIMED = exists().where(projects.c.board_id == boards.c.board_id) @@ -57,21 +70,17 @@ .where(boards.c.board_id == bindparam("board_id")) ) _EXISTS = select(literal(1)).where(boards.c.board_id == bindparam("board_id")) -_LOCK = ( - select(boards.c.user_id, boards.c.board_visibility) - .where(boards.c.board_id == bindparam("board_id")) - .with_for_update() -) +_LOCK = select(boards.c.user_id, _VISIBILITY).where(boards.c.board_id == bindparam("board_id")).with_for_update() _PROJECT_IDS = select(projects.c.board_id, projects.c.project_id).where( projects.c.board_id.in_(bindparam("board_ids", expanding=True)) ) _INSERT = insert(boards) _DELETE_IF_UNCLAIMED = delete(boards).where(boards.c.board_id == bindparam("board_id"), ~_CLAIMED) _IS_SHARED_WITH = select(literal(1)).where( - shared_boards.c.board_id == bindparam("board_id"), shared_boards.c.user_id == bindparam("user_id") + board_members.c.board_id == bindparam("board_id"), board_members.c.user_id == bindparam("user_id") ) -_SHARED_USER_IDS = select(shared_boards.c.user_id).where(shared_boards.c.board_id == bindparam("board_id")) -_IS_SHARED = select(exists().where(shared_boards.c.board_id == bindparam("board_id"))) +_SHARED_USER_IDS = select(board_members.c.user_id).where(board_members.c.board_id == bindparam("board_id")) +_IS_SHARED = select(exists().where(board_members.c.board_id == bindparam("board_id"))) _RENAME = ( update(boards) .where(boards.c.board_id == bindparam("target_board_id")) @@ -89,6 +98,11 @@ boards.c.board_id != bindparam("inbox_id"), ) _RELEASE_MEMBERS = update(boards).where(*_OTHER_MEMBERS).values(project_id=None) +_RELEASE_OWNED = ( + update(boards) + .where(boards.c.user_id == bindparam("target_user_id"), boards.c.project_id.is_not(None)) + .values(project_id=None) +) _DELETE_MEMBERS = delete(boards).where(*_OTHER_MEMBERS) @@ -157,8 +171,8 @@ def _update(changes_owned_state: bool) -> Update: (bindparam("moves_board"), bindparam("new_project_id", type_=boards.c.project_id.type)), else_=boards.c.project_id, ), - board_visibility=func.coalesce( - bindparam("new_board_visibility", type_=boards.c.board_visibility.type), boards.c.board_visibility + general_access=func.coalesce( + bindparam("new_general_access", type_=boards.c.general_access.type), boards.c.general_access ), ) ) @@ -317,8 +331,10 @@ def update(self, conn: Connection, board_id: str, changes: BoardChanges) -> bool "new_board_name": changes.board_name, "new_cover_image_name": changes.cover_image_name, "new_archived": changes.archived, - "new_board_visibility": ( - changes.board_visibility.value if changes.board_visibility is not None else None + "new_general_access": ( + _GENERAL_ACCESS_BY_VISIBILITY[changes.board_visibility] + if changes.board_visibility is not None + else None ), }, ) @@ -347,6 +363,11 @@ def remove_project_members( {"target_user_id": user_id, "target_project_id": project_id, "inbox_id": inbox_id}, ) + @write + def release_owned(self, conn: Connection, user_id: str) -> None: + """Moves every board the account owns to its Library: for the account's deletion, which takes its projects.""" + conn.execute(_RELEASE_OWNED, {"target_user_id": user_id}) + @write def delete_if_unclaimed(self, conn: Connection, board_id: str) -> bool: """Deletes the board unless a project claims it; whether it did.""" diff --git a/invokeai/app/services/shared/database/schema/boards.py b/invokeai/app/services/shared/database/schema/boards.py index 96c0737bb31..04d57fc44e7 100644 --- a/invokeai/app/services/shared/database/schema/boards.py +++ b/invokeai/app/services/shared/database/schema/boards.py @@ -1,6 +1,6 @@ """Boards, who they are shared with, and the media on them.""" -from sqlalchemy import Boolean, Column, ForeignKey, Index +from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index from invokeai.app.services.shared.database.schema.metadata import ( ENUM_LENGTH, @@ -21,29 +21,34 @@ updated_at(), Column("deleted_at", Timestamp()), # unused Column("archived", Boolean(), server_default=default(False)), + # The owner. No foreign key: a deleted account's boards stay, for admins to see and manage. Column("user_id", Key(USER_ID_LENGTH), server_default=default("system")), - Column("is_public", Boolean(), nullable=False, server_default=default(False)), - Column("board_visibility", Key(ENUM_LENGTH), nullable=False, server_default=default("private")), + # The project the board lives in; NULL is the Library. Not a foreign key: `projects.board_id` already refers + # here, and a key each way leaves no order to create or copy the tables in. Project and account deletion + # clear it instead. Column("project_id", Key()), + # The role every other account has on the board: 'restricted' (none), 'viewer' or 'contributor'. + Column("general_access", Key(ENUM_LENGTH), nullable=False, server_default=default("restricted")), + CheckConstraint("general_access IN ('restricted', 'viewer', 'contributor')", name="general_access"), ) -Index("idx_boards_board_visibility", boards.c.board_visibility) Index("idx_boards_created_at", boards.c.created_at) -Index("idx_boards_is_public", boards.c.is_public) Index("idx_boards_user_id", boards.c.user_id) Index("idx_boards_project_id", boards.c.project_id) +Index("idx_boards_general_access", boards.c.general_access) -shared_boards = table( - "shared_boards", +# The accounts a board is shared with, and their role. The owner is never a member: `boards.user_id` is. +board_members = table( + "board_members", Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="CASCADE"), primary_key=True), Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), - Column("can_edit", Boolean(), nullable=False, server_default=default(False)), - inserted_at("shared_at"), + Column("role", LongText(), nullable=False), + Column("granted_by", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="SET NULL")), + inserted_at("granted_at"), + CheckConstraint("role IN ('viewer', 'contributor', 'editor')", name="role"), ) -# The primary key covers it. Only SQLite has it, where a migration created it. -Index("idx_shared_boards_board_id", shared_boards.c.board_id).ddl_if(dialect="sqlite") -Index("idx_shared_boards_user_id", shared_boards.c.user_id) +Index("idx_board_members_user_id", board_members.c.user_id) # An image is on at most one board: its name is the primary key. board_images = table( diff --git a/invokeai/app/services/shared/database/schema/media_references.py b/invokeai/app/services/shared/database/schema/media_references.py index 47b8a76d840..b5f6c2734aa 100644 --- a/invokeai/app/services/shared/database/schema/media_references.py +++ b/invokeai/app/services/shared/database/schema/media_references.py @@ -9,7 +9,7 @@ "media_references", # A MediaReferenceOwnerKind. Column("owner_kind", Key(ENUM_LENGTH), primary_key=True), - # The owning account: project ids are unique per user, not globally. + # The owning account. Column("user_id", Key(USER_ID_LENGTH), primary_key=True), # A project id, a workflow id or a client state key. Column("owner_id", Key(), primary_key=True), diff --git a/invokeai/app/services/shared/database/schema/projects.py b/invokeai/app/services/shared/database/schema/projects.py index 06014242517..aea6b8a559d 100644 --- a/invokeai/app/services/shared/database/schema/projects.py +++ b/invokeai/app/services/shared/database/schema/projects.py @@ -1,6 +1,6 @@ -"""Canvas projects, and the documents of projects that lost their owner.""" +"""Canvas projects, each member's workspace in them, and the documents of projects that lost their owner.""" -from sqlalchemy import CheckConstraint, Column, ForeignKey, PrimaryKeyConstraint +from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, PrimaryKeyConstraint from invokeai.app.services.shared.database.schema.metadata import ( USER_ID_LENGTH, @@ -13,11 +13,12 @@ projects = table( "projects", - # Generated by the client; unique per user, not globally. + # Generated by the client; unique across accounts. Column("project_id", Key(), nullable=False), + # The owner. Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False), Column("name", LongText(), nullable=False), - # The client's project document (JSON). + # The client's project content (JSON); each member's workspace is in `project_workspaces`. Column("data", LongText(), nullable=False), # The project's own board. Unique: at most one project per board. RESTRICT: a claimed board cannot be deleted. Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="RESTRICT"), nullable=False, unique=True), @@ -26,10 +27,29 @@ inserted_at(), updated_at(), Column("minimum_canvas_schema_version", BigInt(), nullable=False, server_default=default(2)), - PrimaryKeyConstraint("user_id", "project_id"), + # The oldest project document schema that can read `data`: a client that understands less is refused it. + Column("minimum_document_schema_version", BigInt(), nullable=False, server_default=default(3)), + PrimaryKeyConstraint("project_id"), CheckConstraint("minimum_canvas_schema_version >= 1", name="minimum_canvas_schema_version"), + CheckConstraint("minimum_document_schema_version >= 1", name="minimum_document_schema_version"), ) +Index("idx_projects_user_id", projects.c.user_id) + +# One account's workspace in a project: its layout, widgets and generation settings (JSON), saved apart from the +# project content so that the two never conflict. +project_workspaces = table( + "project_workspaces", + Column("project_id", Key(), ForeignKey("projects.project_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("data", LongText(), nullable=False), + # Incremented on every update, for optimistic concurrency. + Column("revision", BigInt(), nullable=False, server_default=default(1)), + updated_at(), +) + +Index("idx_project_workspaces_user_id", project_workspaces.c.user_id) + # Where the project boards migration put projects whose owner no longer existed, whole, for repair. Free of # foreign keys, so it can hold what `projects` cannot. Created by that migration only when it had such projects. orphaned_projects_2026_08_06 = table( diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py index 8a80eff69bb..39c6328a0d8 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_08_add_board_project_membership.py @@ -45,6 +45,6 @@ def _add_board_project_membership(context: PortableMigrationContext) -> None: def build_migration() -> PortableMigration: return PortableMigration( id="2026_10_08_add_board_project_membership", - depends_on="2026_10_07_add_session_queue_admission_lock", + depends_on="2026_10_07_drop_sqlite_triggers", callback=_add_board_project_membership, ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py new file mode 100644 index 00000000000..d74773733c5 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py @@ -0,0 +1,134 @@ +"""Share boards by role: members with a role each, and the role everyone else gets. + +`board_members` holds the accounts a board is shared with and their role (viewer, contributor or editor); the +owner is never a member. `boards.general_access` is the role every other account gets: none ('restricted'), +'viewer' or 'contributor'. They replace `board_visibility`, the `is_public` flag it superseded, and +`shared_boards`, which no code ever wrote and whose `can_edit` nothing ever read. + +Visibility maps to general access: private to restricted, shared to viewer, public to contributor. Each +`shared_boards` row becomes a viewer member, whatever its `can_edit`, unless it names the board's own owner. +""" + +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKey, + Table, + case, + column, + exists, + inspect, + literal, + null, + select, + table, +) + +from invokeai.app.services.shared.database.schema.metadata import ENUM_LENGTH, USER_ID_LENGTH, default, inserted_at +from invokeai.app.services.shared.database.types import Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_GENERAL_ACCESS_CHECK = "general_access IN ('restricted', 'viewer', 'contributor')" + +_boards = table("boards", column("board_id"), column("user_id"), column("board_visibility"), column("general_access")) +_shared_boards = table("shared_boards", column("board_id"), column("user_id"), column("shared_at")) +_board_members = table( + "board_members", column("board_id"), column("user_id"), column("role"), column("granted_by"), column("granted_at") +) + + +def _create_board_members(context: PortableMigrationContext) -> None: + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("boards", context.metadata, Column("board_id", Key(), primary_key=True)) + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + context.create_table( + "board_members", + Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("role", LongText(), nullable=False), + Column("granted_by", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="SET NULL")), + inserted_at("granted_at"), + CheckConstraint("role IN ('viewer', 'contributor', 'editor')", name="role"), + ) + + +def _copy_shared_boards(context: PortableMigrationContext) -> None: + """Every share becomes a viewer member. Rows already copied by an interrupted run are skipped.""" + already_member = exists().where( + _board_members.c.board_id == _shared_boards.c.board_id, _board_members.c.user_id == _shared_boards.c.user_id + ) + shares = ( + select( + _shared_boards.c.board_id, _shared_boards.c.user_id, literal("viewer"), null(), _shared_boards.c.shared_at + ) + .select_from(_shared_boards.join(_boards, _boards.c.board_id == _shared_boards.c.board_id)) + .where(_shared_boards.c.user_id != _boards.c.user_id, ~already_member) + ) + context.conn.execute( + _board_members.insert().from_select(["board_id", "user_id", "role", "granted_by", "granted_at"], shares) + ) + + +def _board_roles(context: PortableMigrationContext) -> None: + inspector = inspect(context.conn) + tables = set(inspector.get_table_names()) + + if "board_members" not in tables: + _create_board_members(context) + if "shared_boards" in tables: + _copy_shared_boards(context) + + columns = {entry["name"] for entry in inspect(context.conn).get_columns("boards")} + if "general_access" not in columns: + context.op.add_column( + "boards", Column("general_access", Key(ENUM_LENGTH), nullable=False, server_default=default("restricted")) + ) + if "board_visibility" in columns: + # Repeated by a run that resumes before the column is dropped, with the same result. + context.conn.execute( + _boards.update().values( + general_access=case( + (_boards.c.board_visibility == "shared", "viewer"), + (_boards.c.board_visibility == "public", "contributor"), + else_="restricted", + ) + ) + ) + + indexes = {index["name"] for index in inspect(context.conn).get_indexes("boards")} + for name in ("idx_boards_board_visibility", "idx_boards_is_public"): + if name in indexes: + context.op.drop_index(name, table_name="boards") + + columns = {entry["name"] for entry in inspect(context.conn).get_columns("boards")} + has_check = "ck_boards_general_access" in { + check["name"] for check in inspect(context.conn).get_check_constraints("boards") + } + dropped = [name for name in ("board_visibility", "is_public") if name in columns] + if dropped or not has_check: + # One rebuild on SQLite for every change; plain ALTERs on a server. + with context.op.batch_alter_table("boards") as batch: + for name in dropped: + batch.drop_column(name) + if not has_check: + batch.create_check_constraint("general_access", _GENERAL_ACCESS_CHECK) + + if "idx_boards_general_access" not in {index["name"] for index in inspect(context.conn).get_indexes("boards")}: + context.op.create_index("idx_boards_general_access", "boards", ["general_access"]) + if "idx_board_members_user_id" not in { + index["name"] for index in inspect(context.conn).get_indexes("board_members") + }: + context.op.create_index("idx_board_members_user_id", "board_members", ["user_id"]) + if "shared_boards" in set(inspect(context.conn).get_table_names()): + context.op.drop_table("shared_boards") + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_board_roles", + depends_on="2026_10_08_add_board_project_membership", + callback=_board_roles, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py new file mode 100644 index 00000000000..7e557cf41cb --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_global_project_ids.py @@ -0,0 +1,214 @@ +"""Key projects by `project_id` alone, and clear board memberships that name no project. + +Project ids were unique per account; they become unique across accounts so that a project can be named without +its owner, which sharing needs. Clients have always generated them at random, so two accounts holding the same +id is not expected; where it happens anyway, the oldest project keeps the id and every other one gets a fresh +`project-`, with everything that names it rewritten: its boards' membership, the provenance of its images +and videos, its queue items and their origins, its media references, and the open-project state the web client +keeps for its owner. Each re-keyed project is logged. + +Boards of accounts deleted earlier still name projects that went with the account; those memberships are +cleared, which leaves the boards in their owner's Library, and their number is logged. From now on deleting an +account clears its boards' memberships with it. + +All data changes run before the first DDL: on a server every DDL statement commits on its own, and a run that +fails among the data changes then rolls them back whole instead of leaving half a project re-keyed. Re-keying +runs only while `projects` is still keyed per account, so a resumed run never re-keys twice. +""" + +import json +from typing import Any +from uuid import uuid4 + +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKey, + MetaData, + PrimaryKeyConstraint, + Table, + column, + exists, + func, + inspect, + select, + table, +) + +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, inserted_at, updated_at +from invokeai.app.services.shared.database.types import BigInt, Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_PER_ACCOUNT_TABLE = "projects_per_account" +_ORIGIN_PREFIX = "webv2:p:" + +_projects = table("projects", column("project_id"), column("user_id"), column("created_at"), column("data")) +_boards = table("boards", column("board_id"), column("user_id"), column("project_id")) +_images = table("images", column("user_id"), column("project_id")) +_videos = table("videos", column("user_id"), column("project_id")) +_session_queue = table("session_queue", column("item_id"), column("user_id"), column("project_id"), column("origin")) +_media_references = table("media_references", column("owner_kind"), column("user_id"), column("owner_id")) +_client_state = table("client_state", column("user_id"), column("key"), column("value")) + + +def _is_keyed_by_project_id(context: PortableMigrationContext) -> bool: + return inspect(context.conn).get_pk_constraint("projects")["constrained_columns"] == ["project_id"] + + +def _replace_id(value: Any, old_id: str, new_id: str) -> Any: + """The JSON value with every string equal to the old id, and every key equal to it, replaced.""" + if isinstance(value, str): + return new_id if value == old_id else value + if isinstance(value, list): + return [_replace_id(item, old_id, new_id) for item in value] + if isinstance(value, dict): + return {(new_id if key == old_id else key): _replace_id(item, old_id, new_id) for key, item in value.items()} + return value + + +def _rekey(context: PortableMigrationContext, user_id: str, old_id: str, new_id: str) -> None: + conn = context.conn + # The client's document names its project too; a client opening it under the old id would save to nothing. + document = conn.execute( + select(_projects.c.data).where(_projects.c.user_id == user_id, _projects.c.project_id == old_id) + ).scalar_one() + try: + rewritten_document = json.dumps(_replace_id(json.loads(document), old_id, new_id)) + except ValueError: + rewritten_document = document + conn.execute( + _projects.update() + .where(_projects.c.user_id == user_id, _projects.c.project_id == old_id) + .values(data=rewritten_document) + ) + for target in (_projects, _boards, _images, _videos, _session_queue): + conn.execute( + target.update().where(target.c.user_id == user_id, target.c.project_id == old_id).values(project_id=new_id) + ) + old_prefix = f"{_ORIGIN_PREFIX}{old_id}:" + for item_id, origin in conn.execute( + select(_session_queue.c.item_id, _session_queue.c.origin).where( + _session_queue.c.user_id == user_id, _session_queue.c.origin.startswith(old_prefix, autoescape=True) + ) + ).all(): + conn.execute( + _session_queue.update() + .where(_session_queue.c.item_id == item_id) + .values(origin=f"{_ORIGIN_PREFIX}{new_id}:{origin[len(old_prefix) :]}") + ) + conn.execute( + _media_references.update() + .where( + _media_references.c.owner_kind == "project", + _media_references.c.user_id == user_id, + _media_references.c.owner_id == old_id, + ) + .values(owner_id=new_id) + ) + # The web client's open projects, active project and covers name projects by id. + for key, value in conn.execute( + select(_client_state.c.key, _client_state.c.value).where( + _client_state.c.user_id == user_id, _client_state.c.value.contains(old_id, autoescape=True) + ) + ).all(): + try: + document = json.loads(value) + except ValueError: + continue + rewritten = _replace_id(document, old_id, new_id) + if rewritten != document: + conn.execute( + _client_state.update() + .where(_client_state.c.user_id == user_id, _client_state.c.key == key) + .values(value=json.dumps(rewritten)) + ) + + +def _rekey_duplicates(context: PortableMigrationContext) -> None: + shared_ids = ( + select(_projects.c.project_id).group_by(_projects.c.project_id).having(func.count() > 1).scalar_subquery() + ) + holders = context.conn.execute( + select(_projects.c.project_id, _projects.c.user_id) + .where(_projects.c.project_id.in_(shared_ids)) + .order_by(_projects.c.project_id, _projects.c.created_at, _projects.c.user_id) + ).all() + keeper: dict[str, str] = {} + for project_id, user_id in holders: + if keeper.setdefault(project_id, user_id) == user_id: + continue + new_id = f"project-{uuid4()}" + _rekey(context, user_id, project_id, new_id) + context.logger.warning( + f"Project id {project_id} was held by more than one account; the copy owned by {user_id} is now {new_id}" + ) + + +def _clear_unresolvable_memberships(context: PortableMigrationContext) -> None: + """Boards name projects of their own owner; a membership no such project resolves goes back to the Library.""" + resolves = exists().where(_projects.c.project_id == _boards.c.project_id, _projects.c.user_id == _boards.c.user_id) + cleared = context.conn.execute( + _boards.update().where(_boards.c.project_id.is_not(None), ~resolves).values(project_id=None) + ).rowcount + if cleared: + context.logger.warning(f"{cleared} boards named a project that no longer exists; they are in the Library now") + + +def _rebuild_projects_on_sqlite(context: PortableMigrationContext) -> None: + """SQLite changes a primary key only by rebuilding the table, inside the migration's transaction. The old table + is moved aside first, so the new one is created under its own name and its constraints are named for it. No + table refers to `projects` by foreign key, so the move rewrites none.""" + context.op.rename_table("projects", _PER_ACCOUNT_TABLE) + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + Table("boards", context.metadata, Column("board_id", Key(), primary_key=True)) + rekeyed = context.create_table( + "projects", + Column("project_id", Key(), nullable=False), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False), + Column("name", LongText(), nullable=False), + Column("data", LongText(), nullable=False), + Column("board_id", Key(), ForeignKey("boards.board_id", ondelete="RESTRICT"), nullable=False, unique=True), + Column("revision", BigInt(), nullable=False, server_default=default(1)), + inserted_at(), + updated_at(), + Column("minimum_canvas_schema_version", BigInt(), nullable=False, server_default=default(2)), + PrimaryKeyConstraint("project_id"), + CheckConstraint("minimum_canvas_schema_version >= 1", name="minimum_canvas_schema_version"), + ) + old = Table(_PER_ACCOUNT_TABLE, MetaData(), autoload_with=context.conn) + names = [entry.name for entry in rekeyed.columns] + context.conn.execute(rekeyed.insert().from_select(names, select(*(old.c[name] for name in names)))) + context.op.drop_table(_PER_ACCOUNT_TABLE) + + +def _create_user_index(context: PortableMigrationContext) -> None: + if "idx_projects_user_id" not in {index["name"] for index in inspect(context.conn).get_indexes("projects")}: + context.op.create_index("idx_projects_user_id", "projects", ["user_id"]) + + +def _global_project_ids(context: PortableMigrationContext) -> None: + if not _is_keyed_by_project_id(context): + _rekey_duplicates(context) + _clear_unresolvable_memberships(context) + + if not _is_keyed_by_project_id(context): + if context.conn.dialect.name == "sqlite": + _rebuild_projects_on_sqlite(context) + else: + # The per-account key is also the index the `user_id` foreign key relies on, so the replacement comes + # first. One statement then swaps the key, so a failed run never leaves the table without one. + _create_user_index(context) + context.conn.exec_driver_sql("ALTER TABLE projects DROP PRIMARY KEY, ADD PRIMARY KEY (project_id)") + _create_user_index(context) + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_global_project_ids", + depends_on="2026_10_10_board_roles", + callback=_global_project_ids, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py new file mode 100644 index 00000000000..cb59273b873 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_project_workspaces.py @@ -0,0 +1,63 @@ +"""Give each account its own workspace in a project, and record which document schema a project needs. + +`project_workspaces` holds one account's layout, widgets and generation settings in a project, saved apart from +the project content in `projects.data` so that the two never conflict. Existing projects get no rows: the client +splits a project's document into content and workspace the first time it saves it. + +`projects.minimum_document_schema_version` is the oldest document schema that can read a project, as +`minimum_canvas_schema_version` is for the canvas. Existing projects are at 3; the split raises a project to 4, and +a client that understands less is refused it. +""" + +from sqlalchemy import Column, ForeignKey, Table, inspect + +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, updated_at +from invokeai.app.services.shared.database.types import BigInt, Key, LongText +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( + PortableMigration, + PortableMigrationContext, +) + +_FLOOR = "minimum_document_schema_version" + + +def _project_workspaces(context: PortableMigrationContext) -> None: + if "project_workspaces" not in set(inspect(context.conn).get_table_names()): + # The tables its foreign keys name, as far as the CREATE TABLE needs them. + Table("projects", context.metadata, Column("project_id", Key(), primary_key=True)) + Table("users", context.metadata, Column("user_id", Key(USER_ID_LENGTH), primary_key=True)) + context.create_table( + "project_workspaces", + Column("project_id", Key(), ForeignKey("projects.project_id", ondelete="CASCADE"), primary_key=True), + Column("user_id", Key(USER_ID_LENGTH), ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True), + Column("data", LongText(), nullable=False), + Column("revision", BigInt(), nullable=False, server_default=default(1)), + updated_at(), + ) + if "idx_project_workspaces_user_id" not in { + index["name"] for index in inspect(context.conn).get_indexes("project_workspaces") + }: + context.op.create_index("idx_project_workspaces_user_id", "project_workspaces", ["user_id"]) + + if _FLOOR not in {entry["name"] for entry in inspect(context.conn).get_columns("projects")}: + if context.conn.dialect.name == "sqlite": + # SQLite adds a column with its CHECK in place; Alembic would copy the whole table to add the CHECK. + context.conn.exec_driver_sql( + f"ALTER TABLE projects ADD COLUMN {_FLOOR} INTEGER NOT NULL DEFAULT 3 " + f"CONSTRAINT ck_projects_{_FLOOR} CHECK ({_FLOOR} >= 1)" + ) + else: + context.op.add_column("projects", Column(_FLOOR, BigInt(), nullable=False, server_default=default(3))) + if f"ck_projects_{_FLOOR}" not in { + check["name"] for check in inspect(context.conn).get_check_constraints("projects") + }: + # On a server the CHECK is a statement of its own, which a failed run may not have reached. + context.op.create_check_constraint(_FLOOR, "projects", f"{_FLOOR} >= 1") + + +def build_migration() -> PortableMigration: + return PortableMigration( + id="2026_10_10_project_workspaces", + depends_on="2026_10_10_global_project_ids", + callback=_project_workspaces, + ) diff --git a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py index 162cb29f02d..beb92750421 100644 --- a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py +++ b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py @@ -391,7 +391,11 @@ def _apply_portable_migration(self, migration: PortableMigration) -> None: ) migration.callback( PortableMigrationContext( - conn=conn, op=Operations(MigrationContext.configure(conn)), logger=self._logger + conn=conn, + # The schema metadata's naming convention names what the operations create, as it names what + # the metadata creates. + op=Operations(MigrationContext.configure(conn, opts={"target_metadata": metadata})), + logger=self._logger, ) ) self._check_foreign_keys(conn, violations_before) diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 29094af9762..77b1d24f063 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -155,6 +155,8 @@ def delete_user(q: Queries) -> None: q.locks.acquire(DatabaseLock.ADMIN_ACCOUNTS) self._assert_not_last_admin(q, user_id, is_deleting=True) q.users.delete(user_id) + # The account's projects cascade with it; its boards stay, for admins, in no project. + q.boards.release_owned(user_id) # Projects and client state cascade with the account; the media references they held # would otherwise protect a departed account's intermediates from every cleanup forever. # Workflows and quarantined projects do not cascade, so their references stay with them. diff --git a/tests/app/routers/test_boards_projects.py b/tests/app/routers/test_boards_projects.py index 71570698b24..86b0b8adbc8 100644 --- a/tests/app/routers/test_boards_projects.py +++ b/tests/app/routers/test_boards_projects.py @@ -15,7 +15,7 @@ from sqlalchemy import insert from invokeai.app.services.shared.database.database import Database -from invokeai.app.services.shared.database.schema.boards import shared_boards +from invokeai.app.services.shared.database.schema.boards import board_members from tests.app.routers.conftest import _auth, _create_board @@ -171,7 +171,7 @@ def test_an_explicitly_shared_board_cannot_enter_a_project( board_id = _create_board(client, user1_token, "Shared+with+two") recipient = client.get("/api/v1/auth/me", headers=_auth(user2_token)).json()["user_id"] with mock_sqlite_database.begin(write=True) as conn: - conn.execute(insert(shared_boards).values(board_id=board_id, user_id=recipient)) + conn.execute(insert(board_members).values(board_id=board_id, user_id=recipient, role="viewer")) assert _move(client, user1_token, board_id, project["project_id"]).status_code == status.HTTP_409_CONFLICT diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index cf12b881833..1370dc72f00 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -249,10 +249,10 @@ def _share_board(client: TestClient, token: str, board_id: str) -> None: def _share_board_with_user(db: Database, board_id: str, user_id: str) -> None: - """Insert an explicit per-user share row directly into the shared_boards table.""" + """Insert an explicit per-user share row directly into the board_members table.""" with sqlite_cursor(db) as cursor: cursor.execute( - "INSERT OR IGNORE INTO shared_boards (board_id, user_id) VALUES (?, ?)", + "INSERT OR IGNORE INTO board_members (board_id, user_id, role) VALUES (?, ?, 'viewer')", (board_id, user_id), ) diff --git a/tests/app/services/boards/test_board_storage.py b/tests/app/services/boards/test_board_storage.py index 0614efec1f0..fa4d849c3d0 100644 --- a/tests/app/services/boards/test_board_storage.py +++ b/tests/app/services/boards/test_board_storage.py @@ -19,9 +19,9 @@ from invokeai.app.services.shared.database.queries import board_videos as board_video_queries from invokeai.app.services.shared.database.queries import boards as board_queries from invokeai.app.services.shared.database.schema.boards import board_images as board_images_table +from invokeai.app.services.shared.database.schema.boards import board_members from invokeai.app.services.shared.database.schema.boards import board_videos as board_videos_table from invokeai.app.services.shared.database.schema.boards import boards as boards_table -from invokeai.app.services.shared.database.schema.boards import shared_boards from invokeai.app.services.shared.database.schema.images import images from invokeai.app.services.shared.database.schema.projects import projects from invokeai.app.services.shared.database.schema.users import users @@ -150,8 +150,11 @@ def test_users_list_their_own_shared_and_public_boards(database: Database, board board_records.update(archived, BoardChanges(archived=True)) with database.begin(write=True) as conn: conn.execute( - insert(shared_boards), - [{"board_id": shared_with_alice, "user_id": ALICE}, {"board_id": shared_with_carol, "user_id": CAROL}], + insert(board_members), + [ + {"board_id": shared_with_alice, "user_id": ALICE, "role": "viewer"}, + {"board_id": shared_with_carol, "user_id": CAROL, "role": "viewer"}, + ], ) def listed(*, is_admin: bool, include_archived: bool = False) -> set[str]: @@ -427,3 +430,32 @@ def names(board_id: str, **filters: object) -> set[str]: # endregion + + +@pytest.mark.parametrize( + ("visibility", "general_access"), + [ + (BoardVisibility.Private, "restricted"), + (BoardVisibility.Shared, "viewer"), + (BoardVisibility.Public, "contributor"), + ], +) +def test_board_visibility_is_stored_as_the_matching_general_access( + database: Database, visibility: BoardVisibility, general_access: str +) -> None: + """The mapping the migration converted existing boards with: a board written one way reads back the other.""" + board_records = BoardRecordStorage(database) + written = board_records.save("Written", ALICE).board_id + board_records.update(written, BoardChanges(board_visibility=visibility)) + with database.begin(write=True) as conn: + stored = conn.execute( + select(boards_table.c.general_access).where(boards_table.c.board_id == written) + ).scalar_one() + conn.execute( + insert(boards_table).values( + board_id="stored", board_name="Stored", user_id=ALICE, general_access=general_access + ) + ) + + assert stored == general_access + assert board_records.get("stored").board_visibility == visibility diff --git a/tests/app/services/image_index/test_image_index_records.py b/tests/app/services/image_index/test_image_index_records.py index 9a0302e3d23..0aef511d4dc 100644 --- a/tests/app/services/image_index/test_image_index_records.py +++ b/tests/app/services/image_index/test_image_index_records.py @@ -22,7 +22,7 @@ from invokeai.app.services.image_records.image_records_default import ImageRecordStorage from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries.locks import DatabaseLock -from invokeai.app.services.shared.database.schema.boards import shared_boards +from invokeai.app.services.shared.database.schema.boards import board_members from invokeai.app.services.shared.database.schema.image_index import ( image_embeddings, image_projections, @@ -127,9 +127,9 @@ def _set_created_at(database: Database, kind: str, name: str, created_at: str) - def _share_board(database: Database, board_id: str, user_id: str) -> None: - """No service writes shared_boards yet.""" + """No service writes board members yet.""" with database.begin(write=True) as conn: - conn.execute(insert(shared_boards).values(board_id=board_id, user_id=user_id, can_edit=False)) + conn.execute(insert(board_members).values(board_id=board_id, user_id=user_id, role="viewer")) def imgs(*names: str) -> list[IndexedItem]: @@ -685,9 +685,9 @@ def test_accessible_images_includes_individually_shared_boards( index_records: ImageIndexRecords, other_user_id: str, ) -> None: - # A private board individually shared with other_user via shared_boards + # A private board individually shared with other_user as a viewer member # must expose its images to them — mirroring the board-listing access - # model. No service writes shared_boards yet, so insert the row directly. + # model. No service writes board members yet, so insert the row directly. _save_image(image_records, "own-individually-shared.png", user_id=SYSTEM_USER_ID) index_records.upsert_embedding(IndexedItem("image", "own-individually-shared.png"), MODEL_ID, _vec(1)) board_id = board_records.save("Individually shared", SYSTEM_USER_ID).board_id @@ -713,7 +713,7 @@ def test_accessible_images_includes_boards_owned_by_user( # An image uploaded by another user onto a board the system user OWNS is # accessible to the board owner — matching the gallery "all" listing # (image_records_default), which grants access via boards.user_id even - # without shared/public visibility or a shared_boards row. + # without shared/public visibility or a board membership. _save_image(image_records, "theirs-on-my-board.png", user_id=other_user_id) index_records.upsert_embedding(IndexedItem("image", "theirs-on-my-board.png"), MODEL_ID, _vec(1)) my_board = board_records.save("Mine", SYSTEM_USER_ID).board_id @@ -831,7 +831,7 @@ def test_accessible_images_exclude_individually_shared_archived_board( database: Database, other_user_id: str, ) -> None: - # Archiving is tested for visibility-shared boards; the shared_boards grant is a separate + # Archiving is tested for visibility-shared boards; the membership grant is a separate # branch of the access clause and must be archived-gated too. _save_image(image_records, "granted.png") index_records.upsert_embedding(IndexedItem("image", "granted.png"), MODEL_ID, _vec(1)) diff --git a/tests/app/services/image_records/test_image_records.py b/tests/app/services/image_records/test_image_records.py index 625b50e7874..b9c7e4c928c 100644 --- a/tests/app/services/image_records/test_image_records.py +++ b/tests/app/services/image_records/test_image_records.py @@ -26,7 +26,7 @@ from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries import images as image_queries -from invokeai.app.services.shared.database.schema.boards import shared_boards +from invokeai.app.services.shared.database.schema.boards import board_members from invokeai.app.services.shared.database.schema.images import images from invokeai.app.services.shared.database.schema.users import users from invokeai.app.services.shared.pagination import SQLiteDirection @@ -653,7 +653,7 @@ def add_boarded_image( with database.begin(write=True) as conn: conn.execute(insert(users).values(user_id="user1", email="user1@example.com", password_hash="unused")) - conn.execute(insert(shared_boards).values(board_id=shared_private_id, user_id="user1")) + conn.execute(insert(board_members).values(board_id=shared_private_id, user_id="user1", role="viewer")) @pytest.mark.parametrize( ("user_id", "is_admin", "expected"), diff --git a/tests/app/services/project_records/test_project_records.py b/tests/app/services/project_records/test_project_records.py index fafa85c1df4..e9c0161cd0e 100644 --- a/tests/app/services/project_records/test_project_records.py +++ b/tests/app/services/project_records/test_project_records.py @@ -35,7 +35,7 @@ from invokeai.app.services.shared.database.queries import Queries from invokeai.app.services.shared.database.queries.boards import BoardQueries from invokeai.app.services.shared.database.queries.projects import ProjectQueries -from invokeai.app.services.shared.database.schema.boards import board_images, board_videos, boards, shared_boards +from invokeai.app.services.shared.database.schema.boards import board_images, board_members, board_videos, boards from invokeai.app.services.shared.database.schema.images import images from invokeai.app.services.shared.database.schema.media_references import media_references from invokeai.app.services.shared.database.schema.projects import projects @@ -157,14 +157,14 @@ def test_create_with_client_id_and_duplicate_rejected(project_records: ProjectRe project_records.create(SYSTEM_USER_ID, "Imported again", {"x": 2}, project_id="project-abc") -def test_same_project_id_allowed_for_different_users( - project_records: ProjectRecordsStorage, other_user_id: str -) -> None: +def test_a_project_id_is_unique_across_accounts(project_records: ProjectRecordsStorage, other_user_id: str) -> None: project_records.create(SYSTEM_USER_ID, "Mine", {"owner": "system"}, project_id="project-shared-id") - other = project_records.create(other_user_id, "Theirs", {"owner": "other"}, project_id="project-shared-id") + + with pytest.raises(ProjectRecordExistsError): + project_records.create(other_user_id, "Theirs", {"owner": "other"}, project_id="project-shared-id") assert project_records.get(SYSTEM_USER_ID, "project-shared-id").data == {"owner": "system"} - assert project_records.get(other_user_id, other.project_id).data == {"owner": "other"} + assert [summary.project_id for summary in project_records.list(other_user_id)] == [] def test_list_returns_summaries_of_own_projects_oldest_first( @@ -463,7 +463,11 @@ def _insert_board( with database.begin(write=True) as conn: conn.execute( insert(boards).values( - board_id=board_id, board_name=name, user_id=user_id, board_visibility=visibility, archived=archived + board_id=board_id, + board_name=name, + user_id=user_id, + general_access={"private": "restricted", "shared": "viewer", "public": "contributor"}[visibility], + archived=archived, ) ) return board_id @@ -547,7 +551,7 @@ def test_claiming_an_explicitly_shared_board_is_rejected( ) -> None: _insert_board(database, "lent-out") with database.begin(write=True) as conn: - conn.execute(insert(shared_boards).values(board_id="lent-out", user_id=other_user_id)) + conn.execute(insert(board_members).values(board_id="lent-out", user_id=other_user_id, role="viewer")) with pytest.raises(ProjectBoardUnavailableError): project_records.create(SYSTEM_USER_ID, "P", {}, board_id="lent-out") @@ -895,14 +899,14 @@ def _member_board(database: Database, board_id: str, *, project_id: str, name: s def test_the_snapshot_lists_every_board_of_the_project_inbox_first( project_records: ProjectRecordsStorage, database: Database, other_user_id: str ) -> None: - created = project_records.create(SYSTEM_USER_ID, "Mine", {}, project_id="shared-id") - # The other account's same-id project has members of its own, which must not leak in. - theirs = project_records.create(other_user_id, "Theirs", {}, project_id="shared-id") - _member_board(database, "later", project_id="shared-id", name="Site plan refs") - _member_board(database, "old", project_id="shared-id", name="Old façades", archived=True) + created = project_records.create(SYSTEM_USER_ID, "Mine", {}, project_id="my-id") + # The other account's project has members of its own, which must not leak in. + theirs = project_records.create(other_user_id, "Theirs", {}, project_id="their-id") + _member_board(database, "later", project_id="my-id", name="Site plan refs") + _member_board(database, "old", project_id="my-id", name="Old façades", archived=True) _insert_board(database, "their-member", user_id=other_user_id, name="Stripes") with database.begin(write=True) as conn: - conn.execute(update(boards).where(boards.c.board_id == "their-member").values(project_id="shared-id")) + conn.execute(update(boards).where(boards.c.board_id == "their-member").values(project_id="their-id")) conn.execute(update(boards).where(boards.c.board_id == "old").values(created_at="2020-01-01 00:00:00.000")) _put_image(database, "inbox.png", created.board_id) _put_image(database, "later.png", "later") @@ -1229,15 +1233,14 @@ def test_a_board_in_another_project_cannot_become_an_inbox( def test_deleting_a_project_releases_its_other_boards_to_the_library_by_default( project_records: ProjectRecordsStorage, database: Database, other_user_id: str ) -> None: - created = project_records.create(SYSTEM_USER_ID, "Doomed", {}, project_id="shared-id") - # The other account's same-id project is untouched: membership is keyed by owner too. - theirs = project_records.create(other_user_id, "Theirs", {}, project_id="shared-id") + created = project_records.create(SYSTEM_USER_ID, "Doomed", {}, project_id="doomed-id") + # The other account's project is untouched. + theirs = project_records.create(other_user_id, "Theirs", {}, project_id="their-id") _insert_board(database, "member") _insert_board(database, "their-member", user_id=other_user_id) with database.begin(write=True) as conn: - conn.execute( - update(boards).where(boards.c.board_id.in_(["member", "their-member"])).values(project_id="shared-id") - ) + conn.execute(update(boards).where(boards.c.board_id == "member").values(project_id="doomed-id")) + conn.execute(update(boards).where(boards.c.board_id == "their-member").values(project_id="their-id")) _put_image(database, "kept.png", "member") project_records.delete(SYSTEM_USER_ID, created.project_id) @@ -1255,14 +1258,13 @@ def test_deleting_a_project_releases_its_other_boards_to_the_library_by_default( def test_deleting_a_project_can_take_its_other_boards_but_never_their_media( project_records: ProjectRecordsStorage, database: Database, other_user_id: str ) -> None: - created = project_records.create(SYSTEM_USER_ID, "Doomed", {}, project_id="shared-id") - project_records.create(other_user_id, "Theirs", {}, project_id="shared-id") + created = project_records.create(SYSTEM_USER_ID, "Doomed", {}, project_id="doomed-id") + project_records.create(other_user_id, "Theirs", {}, project_id="their-id") _insert_board(database, "member") _insert_board(database, "their-member", user_id=other_user_id) with database.begin(write=True) as conn: - conn.execute( - update(boards).where(boards.c.board_id.in_(["member", "their-member"])).values(project_id="shared-id") - ) + conn.execute(update(boards).where(boards.c.board_id == "member").values(project_id="doomed-id")) + conn.execute(update(boards).where(boards.c.board_id == "their-member").values(project_id="their-id")) _put_image(database, "kept.png", "member") project_records.delete(SYSTEM_USER_ID, created.project_id, boards="delete") diff --git a/tests/app/services/shared/database/test_schema.py b/tests/app/services/shared/database/test_schema.py index f7f3e3c4596..ebc564c129c 100644 --- a/tests/app/services/shared/database/test_schema.py +++ b/tests/app/services/shared/database/test_schema.py @@ -285,12 +285,7 @@ def test_omitted_columns_take_their_defaults(empty_database: Database) -> None: board = conn.execute(select(boards)).one()._mapping image = conn.execute(select(images.c.image_subfolder, images.c.is_intermediate, images.c.user_id)).one() - assert (board["archived"], board["user_id"], board["is_public"], board["board_visibility"]) == ( - False, - "system", - False, - "private", - ) + assert (board["archived"], board["user_id"], board["general_access"]) == (False, "system", "restricted") for timestamp in (board["created_at"], board["updated_at"]): assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}", timestamp) assert tuple(image) == ("", False, "system") diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_board_roles.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_board_roles.py new file mode 100644 index 00000000000..95732d09755 --- /dev/null +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_board_roles.py @@ -0,0 +1,153 @@ +"""Board visibility and shares become general access and members, from 6.13 and alpha.1 data.""" + +from logging import Logger +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from invokeai.app.services.config.config_default import DefaultInvokeAIAppConfig +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.boards import board_members +from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import MigrationBase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import Migrator + +MIGRATION_ID = "2026_10_10_board_roles" + + +def _run(db: Database, migrations: list[MigrationBase]) -> bool: + migrator = Migrator(db) + for migration in migrations: + migrator.register_migration(migration) + return migrator.run_migrations() + + +def _previous_state(migrations: list[MigrationBase]) -> list[MigrationBase]: + """Every migration except this one and those that depend on it, directly or not.""" + by_id = {migration.id: migration for migration in migrations} + + def depends_on_this(migration: MigrationBase) -> bool: + current: str | None = migration.id + while current is not None: + if current == MIGRATION_ID: + return True + current = by_id[current].depends_on + return False + + return [migration for migration in migrations if not depends_on_this(migration)] + + +@pytest.fixture +def migrations(tmp_path: Path) -> list[MigrationBase]: + config = DefaultInvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + config._root = tmp_path + return build_migrations(MigrationBuildContext(app_config=config, logger=Logger("test"), image_files=MagicMock())) + + +@pytest.fixture +def before(migrations: list[MigrationBase]) -> Database: + """A database just before this migration, holding boards as 6.13 and the alpha left them.""" + db = Database.open_sqlite(None, Logger("test")) + assert _run(db, _previous_state(migrations)) + with db.begin(write=True) as conn: + conn.exec_driver_sql( + "INSERT INTO users (user_id, email, display_name, password_hash) VALUES (?, ?, ?, 'x');", + [("alice", "alice@example.com", "Alice"), ("bob", "bob@example.com", "Bob")], + ) + conn.exec_driver_sql( + "INSERT INTO boards (board_id, board_name, user_id, is_public, board_visibility) VALUES (?, ?, ?, ?, ?);", + [ + ("private", "Private", "alice", 0, "private"), + ("shared", "Shared", "alice", 0, "shared"), + ("public", "Public", "alice", 1, "public"), + ("lent", "Lent", "alice", 0, "private"), + ], + ) + conn.exec_driver_sql( + "INSERT INTO shared_boards (board_id, user_id, can_edit, shared_at) VALUES (?, ?, ?, ?);", + [ + ("lent", "bob", 1, "2026-01-02 03:04:05.000"), + # A share naming the board's own owner grants nothing. + ("lent", "alice", 0, "2026-01-02 03:04:05.000"), + ], + ) + return db + + +def _general_access(db: Database) -> dict[str, str]: + return {row[0]: row[1] for row in db.sqlite.conn.execute("SELECT board_id, general_access FROM boards;")} + + +def _members(db: Database) -> list[tuple[str, str, str, str | None, str]]: + rows = db.sqlite.conn.execute( + "SELECT board_id, user_id, role, granted_by, granted_at FROM board_members ORDER BY board_id, user_id;" + ).fetchall() + return [tuple(row) for row in rows] + + +def _columns(db: Database) -> set[str]: + return {row[1] for row in db.sqlite.conn.execute("PRAGMA table_info(boards);").fetchall()} + + +def test_visibility_becomes_general_access_and_shares_become_viewer_members( + before: Database, migrations: list[MigrationBase] +) -> None: + assert _run(before, migrations) + + assert _general_access(before) == { + "private": "restricted", + "shared": "viewer", + "public": "contributor", + "lent": "restricted", + } + assert _members(before) == [("lent", "bob", "viewer", None, "2026-01-02 03:04:05.000")] + assert not {"board_visibility", "is_public"} & _columns(before) + tables = {row[0] for row in before.sqlite.conn.execute("SELECT name FROM sqlite_master WHERE type='table';")} + assert "shared_boards" not in tables + assert not _run(before, migrations) + before.dispose() + + +def test_general_access_and_roles_are_checked(before: Database, migrations: list[MigrationBase]) -> None: + assert _run(before, migrations) + + with pytest.raises(Exception, match="CHECK"): + before.sqlite.conn.execute("UPDATE boards SET general_access = 'editor' WHERE board_id = 'private';") + with pytest.raises(Exception, match="CHECK"): + before.sqlite.conn.execute( + "INSERT INTO board_members (board_id, user_id, role) VALUES ('private', 'bob', 'owner');" + ) + before.dispose() + + +def test_a_run_that_stopped_halfway_finishes_the_work(before: Database, migrations: list[MigrationBase]) -> None: + """On a server each DDL statement commits on its own: a failed run can leave the new column behind, still at + its default, with everything else left to do.""" + with before.begin(write=True) as conn: + conn.exec_driver_sql("ALTER TABLE boards ADD COLUMN general_access VARCHAR(32) NOT NULL DEFAULT 'restricted';") + + assert _run(before, migrations) + + assert _general_access(before)["public"] == "contributor" + assert _members(before) == [("lent", "bob", "viewer", None, "2026-01-02 03:04:05.000")] + assert not {"board_visibility", "is_public"} & _columns(before) + before.dispose() + + +def test_a_run_that_stopped_after_copying_members_does_not_copy_them_twice( + before: Database, migrations: list[MigrationBase] +) -> None: + """On a server the new table commits as it is created: a run that failed after the copy leaves the members in + it and `shared_boards` still there.""" + with before.begin(write=True) as conn: + board_members.create(conn) + conn.exec_driver_sql( + "INSERT INTO board_members (board_id, user_id, role, granted_at) " + "VALUES ('lent', 'bob', 'viewer', '2026-01-02 03:04:05.000');" + ) + + assert _run(before, migrations) + + assert _members(before) == [("lent", "bob", "viewer", None, "2026-01-02 03:04:05.000")] + before.dispose() diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_global_project_ids.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_global_project_ids.py new file mode 100644 index 00000000000..607b9302e71 --- /dev/null +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_global_project_ids.py @@ -0,0 +1,220 @@ +"""Projects become keyed by id alone: a shared id is re-keyed everywhere, and memberships no project resolves go.""" + +import json +import logging +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from invokeai.app.services.config.config_default import DefaultInvokeAIAppConfig +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import MigrationBase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import Migrator + +MIGRATION_ID = "2026_10_10_global_project_ids" +LOGGER = "test_global_project_ids" + + +def _run(db: Database, migrations: list[MigrationBase]) -> bool: + migrator = Migrator(db) + for migration in migrations: + migrator.register_migration(migration) + return migrator.run_migrations() + + +def _previous_state(migrations: list[MigrationBase]) -> list[MigrationBase]: + """Every migration except this one and those that depend on it, directly or not.""" + by_id = {migration.id: migration for migration in migrations} + + def depends_on_this(migration: MigrationBase) -> bool: + current: str | None = migration.id + while current is not None: + if current == MIGRATION_ID: + return True + current = by_id[current].depends_on + return False + + return [migration for migration in migrations if not depends_on_this(migration)] + + +@pytest.fixture +def migrations(tmp_path: Path) -> list[MigrationBase]: + config = DefaultInvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + config._root = tmp_path + return build_migrations( + MigrationBuildContext(app_config=config, logger=logging.getLogger(LOGGER), image_files=MagicMock()) + ) + + +@pytest.fixture +def before(migrations: list[MigrationBase]) -> Database: + """Alice and Bob each hold a project called `dup`. Bob's is the older one although Alice sorts first and her row + was written first; a board of Alice's names a project that went with a deleted account.""" + db = Database.open_sqlite(None, logging.getLogger(LOGGER)) + assert _run(db, _previous_state(migrations)) + with db.begin(write=True) as conn: + execute = conn.exec_driver_sql + execute( + "INSERT INTO users (user_id, email, display_name, password_hash) VALUES (?, ?, ?, 'x');", + [("alice", "alice@example.com", "Alice"), ("bob", "bob@example.com", "Bob")], + ) + execute( + "INSERT INTO boards (board_id, board_name, user_id, project_id) VALUES (?, ?, ?, ?);", + [ + ("alice-inbox", "Dup", "alice", "dup"), + ("bob-inbox", "Dup", "bob", "dup"), + ("bob-member", "Refs", "bob", "dup"), + ("alice-stray", "Stray", "alice", "gone"), + ], + ) + execute( + "INSERT INTO projects (project_id, user_id, name, data, board_id, revision, created_at, updated_at, " + "minimum_canvas_schema_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);", + [ + ( + "dup", + "alice", + "Alice's dup", + json.dumps({"id": "dup", "canvas": {"layers": [1]}}), + "alice-inbox", + 7, + "2026-02-01 00:00:00.000", + "2026-03-01 00:00:00.000", + 3, + ), + ( + "dup", + "bob", + "Bob's dup", + json.dumps({"id": "dup", "name": "dup"}), + "bob-inbox", + 4, + "2026-01-01 00:00:00.000", + "2026-01-02 00:00:00.000", + 2, + ), + ], + ) + for user_id in ("alice", "bob"): + execute( + "INSERT INTO images (image_name, image_origin, image_category, width, height, user_id, project_id) " + "VALUES (?, 'internal', 'general', 1, 1, ?, 'dup');", + (f"{user_id}.png", user_id), + ) + execute( + "INSERT INTO videos (video_name, video_origin, video_category, width, height, user_id, project_id) " + "VALUES (?, 'internal', 'general', 1, 1, ?, 'dup');", + (f"{user_id}.mp4", user_id), + ) + execute( + "INSERT INTO session_queue (batch_id, queue_id, session_id, session, user_id, project_id, origin) " + "VALUES ('b', 'default', ?, '{}', ?, 'dup', 'webv2:p:dup:q:1');", + (f"{user_id}-session", user_id), + ) + execute( + "INSERT INTO media_references (owner_kind, user_id, owner_id, media_kind, media_name) " + "VALUES ('project', ?, 'dup', 'image', ?);", + (user_id, f"{user_id}.png"), + ) + execute( + "INSERT INTO client_state (user_id, key, value) VALUES (?, 'webv2:workbench-account', ?);", + (user_id, json.dumps({"activeProjectId": "dup", "openProjectIds": ["dup", "other"]})), + ) + execute( + "INSERT INTO client_state (user_id, key, value) VALUES (?, 'webv2:project-covers', ?);", + (user_id, json.dumps({"coverImageNames": {"dup": f"{user_id}.png"}})), + ) + return db + + +def _scalar(db: Database, sql: str, *parameters: str) -> object: + return db.sqlite.conn.execute(sql, parameters).fetchone()[0] + + +def _projects(db: Database) -> dict[str, tuple[object, ...]]: + rows = db.sqlite.conn.execute( + "SELECT user_id, project_id, name, data, board_id, revision, created_at, updated_at, " + "minimum_canvas_schema_version FROM projects;" + ).fetchall() + return {row[0]: tuple(row[1:]) for row in rows} + + +def _client_state(db: Database, user_id: str) -> dict[str, object]: + rows = db.sqlite.conn.execute("SELECT key, value FROM client_state WHERE user_id = ?;", (user_id,)).fetchall() + return {row[0]: json.loads(row[1]) for row in rows} + + +def test_a_shared_id_stays_with_the_oldest_project_and_the_other_is_rekeyed_everywhere( + before: Database, migrations: list[MigrationBase], caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER): + assert _run(before, migrations) + + projects = _projects(before) + alice_id = projects["alice"][0] + assert projects["bob"][0] == "dup" + assert isinstance(alice_id, str) and alice_id.startswith("project-") + # Every value survives the rebuild; the re-keyed document names its new id. + assert projects == { + "alice": ( + alice_id, + "Alice's dup", + json.dumps({"id": alice_id, "canvas": {"layers": [1]}}), + "alice-inbox", + 7, + "2026-02-01 00:00:00.000", + "2026-03-01 00:00:00.000", + 3, + ), + "bob": ( + "dup", + "Bob's dup", + json.dumps({"id": "dup", "name": "dup"}), + "bob-inbox", + 4, + "2026-01-01 00:00:00.000", + "2026-01-02 00:00:00.000", + 2, + ), + } + + for user_id, expected in (("alice", alice_id), ("bob", "dup")): + for sql in ( + "SELECT project_id FROM images WHERE user_id = ?;", + "SELECT project_id FROM videos WHERE user_id = ?;", + "SELECT project_id FROM session_queue WHERE user_id = ?;", + "SELECT owner_id FROM media_references WHERE user_id = ?;", + ): + assert _scalar(before, sql, user_id) == expected, sql + assert _scalar(before, "SELECT origin FROM session_queue WHERE user_id = ?;", user_id) == ( + f"webv2:p:{expected}:q:1" + ) + assert _client_state(before, user_id) == { + "webv2:workbench-account": {"activeProjectId": expected, "openProjectIds": [expected, "other"]}, + "webv2:project-covers": {"coverImageNames": {expected: f"{user_id}.png"}}, + } + boards = dict(before.sqlite.conn.execute("SELECT board_id, project_id FROM boards;").fetchall()) + assert boards == {"alice-inbox": alice_id, "bob-inbox": "dup", "bob-member": "dup", "alice-stray": None} + assert f"the copy owned by alice is now {alice_id}" in caplog.text + assert "1 boards named a project that no longer exists" in caplog.text + before.dispose() + + +def test_a_run_that_stopped_after_the_data_changes_finishes_the_work( + before: Database, migrations: list[MigrationBase] +) -> None: + """On a server the data changes commit with the first DDL: a failed run can leave the owner index created and + the per-account key still in place, with no duplicate left to re-key.""" + with before.begin(write=True) as conn: + conn.exec_driver_sql("UPDATE projects SET project_id = 'renamed' WHERE user_id = 'alice';") + conn.exec_driver_sql("UPDATE boards SET project_id = 'renamed' WHERE board_id = 'alice-inbox';") + conn.exec_driver_sql("CREATE INDEX idx_projects_user_id ON projects (user_id);") + + assert _run(before, migrations) + + assert {user_id: values[0] for user_id, values in _projects(before).items()} == {"alice": "renamed", "bob": "dup"} + key = [row[1] for row in before.sqlite.conn.execute("PRAGMA table_info(projects);").fetchall() if row[5]] + assert key == ["project_id"] + before.dispose() diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_project_workspaces.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_project_workspaces.py new file mode 100644 index 00000000000..ead50b17def --- /dev/null +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_10_project_workspaces.py @@ -0,0 +1,79 @@ +"""Existing projects need document schema 3 and have no workspaces yet; workspaces go with their project and account.""" + +from logging import Logger +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from invokeai.app.services.config.config_default import DefaultInvokeAIAppConfig +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.sqlite_migrator.migration_loader import MigrationBuildContext, build_migrations +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import MigrationBase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import Migrator + +MIGRATION_ID = "2026_10_10_project_workspaces" + + +def _run(db: Database, migrations: list[MigrationBase]) -> bool: + migrator = Migrator(db) + for migration in migrations: + migrator.register_migration(migration) + return migrator.run_migrations() + + +def _previous_state(migrations: list[MigrationBase]) -> list[MigrationBase]: + """Every migration except this one and those that depend on it, directly or not.""" + by_id = {migration.id: migration for migration in migrations} + + def depends_on_this(migration: MigrationBase) -> bool: + current: str | None = migration.id + while current is not None: + if current == MIGRATION_ID: + return True + current = by_id[current].depends_on + return False + + return [migration for migration in migrations if not depends_on_this(migration)] + + +@pytest.fixture +def migrated(tmp_path: Path) -> Database: + """A project of Alice's from before this migration, then the migration.""" + config = DefaultInvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + config._root = tmp_path + migrations = build_migrations( + MigrationBuildContext(app_config=config, logger=Logger("test"), image_files=MagicMock()) + ) + db = Database.open_sqlite(None, Logger("test")) + assert _run(db, _previous_state(migrations)) + with db.begin(write=True) as conn: + conn.exec_driver_sql( + "INSERT INTO users (user_id, email, display_name, password_hash) " + "VALUES ('alice', 'alice@example.com', 'Alice', 'x');" + ) + conn.exec_driver_sql("INSERT INTO boards (board_id, board_name, user_id) VALUES ('inbox', 'P', 'alice');") + conn.exec_driver_sql( + "INSERT INTO projects (project_id, user_id, name, data, board_id) VALUES ('p', 'alice', 'P', '{}', 'inbox');" + ) + assert _run(db, migrations) + assert not _run(db, migrations) + return db + + +def test_existing_projects_need_document_schema_3_and_have_no_workspaces(migrated: Database) -> None: + conn = migrated.sqlite.conn + assert conn.execute("SELECT minimum_document_schema_version FROM projects;").fetchone()[0] == 3 + assert conn.execute("SELECT COUNT(*) FROM project_workspaces;").fetchone()[0] == 0 + with pytest.raises(Exception, match="CHECK"): + conn.execute("UPDATE projects SET minimum_document_schema_version = 0;") + migrated.dispose() + + +@pytest.mark.parametrize("deleted", ["DELETE FROM projects;", "DELETE FROM users WHERE user_id = 'alice';"]) +def test_a_workspace_goes_with_its_project_and_its_account(migrated: Database, deleted: str) -> None: + conn = migrated.sqlite.conn + conn.execute("INSERT INTO project_workspaces (project_id, user_id, data) VALUES ('p', 'alice', '{}');") + conn.execute(deleted) + assert conn.execute("SELECT COUNT(*) FROM project_workspaces;").fetchone()[0] == 0 + migrated.dispose() diff --git a/tests/app/services/users/test_user_service.py b/tests/app/services/users/test_user_service.py index 12fc7e9018b..c1f19f2edf7 100644 --- a/tests/app/services/users/test_user_service.py +++ b/tests/app/services/users/test_user_service.py @@ -6,8 +6,10 @@ from pydantic import ValidationError from sqlalchemy import insert, select, update +from invokeai.app.services.project_records.project_records_default import ProjectRecordsStorage from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries.base import IN_CHUNK +from invokeai.app.services.shared.database.schema.boards import boards from invokeai.app.services.shared.database.schema.media_references import media_references from invokeai.app.services.shared.database.schema.users import users as users_table from invokeai.app.services.users import users_default @@ -183,6 +185,23 @@ def test_delete_user_drops_references_only_of_documents_that_cascade(user_servic assert remaining == {(leaving, "quarantined_project"), (leaving, "workflow")} | {(staying, kind) for kind in kinds} +def test_delete_user_moves_its_boards_out_of_the_projects_that_go_with_it( + user_service: UserService, database: Database +): + leaving = user_service.create(UserCreateRequest(email="leaving@example.com", password="TestPassword123")).user_id + staying = user_service.create(UserCreateRequest(email="staying@example.com", password="TestPassword123")).user_id + projects = ProjectRecordsStorage(database) + theirs = projects.create(leaving, "Leaving", {}) + kept = projects.create(staying, "Staying", {}) + + user_service.delete(leaving) + + with database.begin(write=False) as conn: + membership = dict(conn.execute(select(boards.c.board_id, boards.c.project_id)).all()) + # The account's boards stay, for admins, in no project; nobody else's move. + assert membership == {theirs.board_id: None, kept.board_id: kept.project_id} + + def test_an_account_created_meanwhile_with_the_same_email_is_refused( user_service: UserService, database: Database, monkeypatch: pytest.MonkeyPatch ): From cfa6d08ee203ee85adc9407109fa12c5940e1301 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 04:27:08 -0600 Subject: [PATCH 02/16] feat(boards): decide board and item access by role in one service Add a board access service that grants viewer, contributor, editor and owner actions from membership and general access; routers, graph execution, workflow calls and upload events all ask it. Boards report general_access, my_role and allowed_actions, keep board_visibility as a deprecated alias, and answer 404 when unreadable and 403 when an action is not allowed. Drop the unused general_access index, and document the roles and the API changes. --- .../06.Gallery/gallery-reference.mdx | 2 +- .../Multi-User Mode/api-guide.mdx | 29 +- .../Multi-User Mode/user-guide.mdx | 29 +- .../development/Front End/project-files.mdx | 2 +- .../development/Guides/database-layer.mdx | 6 +- .../docs/troubleshooting/known-bugs.mdx | 4 +- invokeai/app/api/routers/_access.py | 243 ++++----------- invokeai/app/api/routers/board_images.py | 24 +- invokeai/app/api/routers/boards.py | 68 +++-- invokeai/app/api/routers/images.py | 43 ++- invokeai/app/api/routers/videos.py | 49 ++- invokeai/app/api/sockets.py | 30 +- .../board_access/board_access_common.py | 169 +++++++++++ .../board_access/board_access_default.py | 120 ++++++++ .../board_records/board_records_base.py | 14 +- .../board_records/board_records_common.py | 66 +++-- .../board_records/board_records_default.py | 16 +- invokeai/app/services/boards/boards_base.py | 12 +- invokeai/app/services/boards/boards_common.py | 34 ++- .../app/services/boards/boards_default.py | 110 ++++--- invokeai/app/services/events/events_base.py | 21 +- invokeai/app/services/events/events_common.py | 23 -- invokeai/app/services/invocation_services.py | 5 + .../project_records_default.py | 7 +- .../session_processor/workflow_call_batch.py | 51 +--- .../shared/database/queries/boards.py | 95 +++--- .../services/shared/database/schema/boards.py | 4 +- .../app/services/shared/invocation_context.py | 108 ++++--- .../migration_2026_10_10_board_roles.py | 6 +- invokeai/frontend/api/openapi.json | 177 +++++------ invokeai/frontend/api/schema.ts | 99 ++++--- tests/app/api/test_video_upload_limits.py | 7 +- .../routers/test_board_images_maintenance.py | 84 +----- tests/app/routers/test_boards_multiuser.py | 185 +++++++++++- tests/app/routers/test_boards_projects.py | 63 ++-- .../app/routers/test_gallery_item_location.py | 8 +- tests/app/routers/test_image_map.py | 2 +- tests/app/routers/test_images.py | 16 +- tests/app/routers/test_media_copy.py | 16 +- .../routers/test_multiuser_authorization.py | 232 +++++++++------ tests/app/routers/test_upload_events.py | 85 +----- tests/app/routers/test_videos_multiuser.py | 68 ++--- .../app/services/auth/test_data_isolation.py | 69 +++-- .../board_access/test_board_access.py | 279 ++++++++++++++++++ .../app/services/boards/test_board_storage.py | 11 +- .../services/boards/test_boards_default.py | 58 ++-- .../shared/test_invocation_context_boards.py | 59 ++++ .../shared/test_invocation_context_images.py | 51 ++-- .../shared/test_invocation_context_videos.py | 47 +-- .../app/services/test_workflow_call_batch.py | 60 ++-- .../app/services/workflow_call_test_utils.py | 8 + tests/app/test_media_upload_socketio.py | 117 ++++---- 52 files changed, 1934 insertions(+), 1257 deletions(-) create mode 100644 invokeai/app/services/board_access/board_access_common.py create mode 100644 invokeai/app/services/board_access/board_access_default.py create mode 100644 tests/app/services/board_access/test_board_access.py diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 5bf24568bf9..e48d95041ad 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -25,7 +25,7 @@ Hover over a board and click its **⋮** button, or right-click it, to open its * **Auto-add to this board** — makes this the board that receives new results, marked **Auto**, whichever board is selected. On the auto-add board, the item is **Stop auto-adding here**, which goes back to sending results to the selected board. Not offered for date boards or archived boards. See [Choosing where new media goes](/users-guide/gallery/introduction/#choosing-where-new-media-goes). * **Download Board** — downloads the board (see [below](#downloading-a-board)). * **Rename board** — opens a dialog to change the name. -* **Move to…** — moves the board to the **Library**, into the open project, or into one of your other projects. A board in a project is private to you. +* **Move to…** — moves the board to the **Library**, into the open project, or into one of your other projects. A board keeps its general access when it moves. * **Archive Board** / **Unarchive Board** — see [below](#archiving-a-board). * **Delete Board** — see [below](#deleting-a-board). diff --git a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx index 2a33baba35e..15e866447ee 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx @@ -528,7 +528,7 @@ Resources are now filtered by the authenticated user: GET /api/v1/boards/?all=true # Returns all boards # After (multi-user) -GET /api/v1/boards/?all=true # Returns boards the current user can access, including their own boards plus shared/public boards; admins can see all boards +GET /api/v1/boards/?all=true # Returns boards the current user can access, including their own boards, boards they are a member of and boards with general access; admins can see all boards ``` **Images:** @@ -859,7 +859,11 @@ On an error, it returns `422 Unprocessable Content` and the following JSON: "deleted_at": "2026-05-07T03:04:00.738Z", "cover_image_name": "string", "archived": false, + "general_access": "restricted", "board_visibility": "private", + "my_role": "owner", + "allowed_actions": ["read", "add_media", "manage_items", "rename", "set_cover", "archive", "set_general_access", "move", "delete"], + "admin_override": false, "image_count": 0, "asset_count": 0, "owner_username": "string" @@ -869,11 +873,20 @@ On an error, it returns `422 Unprocessable Content` and the following JSON: ``` This returns a paged response. See the swagger page (`http://localhost:9090/docs#/boards/list_boards`) for details. -The `board_visibility` field will be one of: +Each board describes the caller's access to it: -- `private` -- private to the owner and administrator -- `shared` -- read/write to the owner and administrator, read-only to everyone else -- `public` -- read/write by everyone +- `general_access` -- the role every other user gets: `restricted` (none), `viewer` or `contributor`. Only the owner or an administrator may change it, with `PATCH /api/v1/boards/{board_id}`. +- `my_role` -- the caller's own role: `viewer`, `contributor`, `editor` or `owner`; `null` when only administrator rights give access. +- `allowed_actions` -- the board actions the caller may take. Clients should decide what to offer from this list rather than from `my_role`. +- `admin_override` -- `true` when administrator rights give the caller more than `my_role` does. +- `owner_username` -- the owner's display name. Administrators see the email of an owner without one; other users see `Unknown user`. +- `project_id` -- shown only to the board's owner and administrators. + +A board the caller cannot open answers `404`, the same as one that does not exist; an action the caller's role does not allow answers `403`. A project's inbox refuses renaming, archiving, moving, deleting and a change of general access with `409`; those follow its project. + +:::caution[Deprecated] +`board_visibility` is `general_access` under its pre-7.0 name (`private` = `restricted`, `shared` = `viewer`, `public` = `contributor`). It is still returned and accepted on update, but sending it together with `general_access` is refused with `422`. It will be removed in 8.0. +::: #### Get One Board @@ -891,10 +904,14 @@ The `board_visibility` field will be one of: "deleted_at": null, "cover_image_name": "08689e4b-f084-4c49-83a8-4fc1edb167c4.png", "archived": false, + "general_access": "viewer", "board_visibility": "shared", + "my_role": "viewer", + "allowed_actions": ["read"], + "admin_override": false, "image_count": 55, "asset_count": 0, - "owner_username": null + "owner_username": "Sam" } ``` diff --git a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx index 8e96cd8465d..d2e1e2d37d1 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx @@ -118,7 +118,7 @@ As a regular user, you can: - View redacted information for jobs being run by other users - Customize your UI preferences (theme, hotkeys, etc.) - View available models (read-only access to Model Manager) -- View shared and public boards created by other users +- Open boards other users have given them access to, and add to the ones they can contribute to - View and use workflows marked as shared by other users You cannot: @@ -154,15 +154,28 @@ Administrators have all regular user capabilities, plus: ### Image Boards -In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels: +In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit, in the Library or inside a project. What another user may do with your board depends on their **role** on it: -- **Private** (default): Only you (and administrators) can see and modify the board. -- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images). -- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete). +| Role | Can | +| --- | --- | +| **Viewer** | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. | +| **Contributor** | Everything a viewer can, and add their own images and videos to the board. They can remove, star or delete only what they added. | +| **Editor** | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. | +| **Owner** | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. | -To change a board's visibility, right-click on the board and select the desired visibility option. +A board's **general access** sets the role every other user gets: -Administrators can see and manage all users' image boards and their contents regardless of visibility settings. +- **Restricted** (default): no one else can open the board. +- **Everyone can view**: every user is a viewer. +- **Everyone can contribute**: every user is a contributor. + +This release does not yet let an owner give a role to one user at a time, so general access is how other users get access to a board. + +Only the person who added an image or video, or an administrator, can delete it permanently. When an owner or editor removes someone else's item from a board, it returns to that person's **Uncategorized** board. Deleting a board together with its media likewise deletes only your own media; other people's items return to their Uncategorized boards. + +A project's **Inbox** stays restricted; its access follows the project. Other boards inside a project can have any general access, and they keep it when they move between the Library and a project. + +A board you cannot open is reported as not found. Administrators can open and manage every user's boards; a board's owner and administrators are the only ones who can change its general access. ### Going From Multi-User to Single-User Mode @@ -306,7 +319,7 @@ These settings are stored per-user and won't affect other users. ### Can other users see my images? -Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default. +Not unless you give them access. Boards start **restricted**: only you and administrators can open them. A board set to **Everyone can view** or **Everyone can contribute** is open to every user. ### Can I share my workflows with others? diff --git a/docs/src/content/docs/development/Front End/project-files.mdx b/docs/src/content/docs/development/Front End/project-files.mdx index 515a8650cee..5f9043f4da1 100644 --- a/docs/src/content/docs/development/Front End/project-files.mdx +++ b/docs/src/content/docs/development/Front End/project-files.mdx @@ -10,7 +10,7 @@ Import also accepts the `.invokeproject.json` envelope shipped by earlier webv2 ## Boards belong to projects -A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects` is keyed by `(user_id, project_id)` and adding a composite constraint would mean rebuilding `boards` under enforced cascades. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. Boards in a project are private and unshared; a move or visibility change that would make a member otherwise is refused with `409`. +A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects` is keyed by `(user_id, project_id)` and adding a composite constraint would mean rebuilding `boards` under enforced cascades. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. A board keeps its general access and memberships when it moves between the Library and a project; only the inbox's access follows its project. Every project also has an **inbox**: the one board `projects.board_id` names (`NOT NULL UNIQUE`, foreign key `ON DELETE RESTRICT`). It is created with the project, is always a member of it, and is the only board the project APIs manage. diff --git a/docs/src/content/docs/development/Guides/database-layer.mdx b/docs/src/content/docs/development/Guides/database-layer.mdx index 2e2115bc397..74637908e3c 100644 --- a/docs/src/content/docs/development/Guides/database-layer.mdx +++ b/docs/src/content/docs/development/Guides/database-layer.mdx @@ -43,6 +43,10 @@ What backends spell differently comes from `dialect.py`: - `JsonString(column, path)` is the string at a member path of the JSON document in a text column, and NULL when the document is not valid JSON or the member is missing or holds anything else. SQLite's `json_extract()` alone would give 1 for a member holding true, where a server gives 'true', and raise on a document that is not JSON. - `OrderedJoin(left, right, onclause)` is an inner join, and only that, whose left side SQLite plans as the outer loop: it renders as `CROSS JOIN ... ON` there, which SQLite's planner never reorders, and as a plain `JOIN` elsewhere. For a join that must stay proportional to its left side, such as a board's membership rather than every image an index offers first. +### Board access + +Who may read or change a board is decided in one place on each side of the layer. In SQL, `queries/board_access.py` holds `readable_board`, the condition every listing of boards or of media through boards applies for an account that is not an administrator. In Python, the `board_access` service (`services/board_access/`) decides single boards and items: `MINIMUM_ROLE` in `board_access_common.py` is the one table of the least role each board action needs, and routers, graph execution, workflow-call expansion and socket routing all ask the service rather than reading `general_access` or `board_members` themselves. A single board's role facts come from `q.boards.access_facts()`, and the caller's roles across a page of listed boards from `q.boards.member_roles()`. A new rule changes both sides, and `tests/app/services/board_access/` holds the role matrix and a test that the board listing shows exactly the boards the service lets the caller read. + ## Schema The tables are SQLAlchemy `Table`s in `schema/`, one module per domain, all in `schema.metadata`. They are the one definition of the schema: query modules build their statements from them, and a server database is created from them. @@ -150,7 +154,7 @@ Image moves take no database lock. Their jobs are created, worked and committed The font catalog takes none either, for the same reason. An upload checks for the same content and for the quota, writes its file and inserts its row under the font service's managed-storage lock, which the sweep for orphaned files takes too; the file is written outside any transaction. Directory scans run one at a time under a lock of their own, so two cannot insert the same new font. -A decision about one row needs no named lock: the row is the lock. The transaction locks the row before it reads what it decides on, with a query method marked `@locking` that selects it `FOR UPDATE` (`q.boards.lock(board_id)`, `q.projects.lock(user_id, project_id)`). Every transaction that changes what the decision reads writes or locks the same row, so it waits until the decision has committed, and the decision sees what such a transaction committed before. A project save works this way, so its revision and compatibility checks apply to the row it writes, and so does a project claiming a board: a board made public, deleted or claimed by another project meanwhile is either seen by the claim or finds the board claimed. Row locks, too, are held only inside a `transaction()`; called on its own, a lock method raises. +A decision about one row needs no named lock: the row is the lock. The transaction locks the row before it reads what it decides on, with a query method marked `@locking` that selects it `FOR UPDATE` (`q.boards.lock(board_id)`, `q.projects.lock(user_id, project_id)`). Every transaction that changes what the decision reads writes or locks the same row, so it waits until the decision has committed, and the decision sees what such a transaction committed before. A project save works this way, so its revision and compatibility checks apply to the row it writes, and so does a project claiming a board: a board opened to other users, deleted or claimed by another project meanwhile is either seen by the claim or finds the board claimed. Row locks, too, are held only inside a `transaction()`; called on its own, a lock method raises. Rows are locked in one order, a project's before its board's, so two transactions that lock both cannot deadlock on them. A single statement that locks a board and then reads its project, like the boards service's guarded update and delete, cannot keep that order; the rare deadlock it causes with a project write is retried by `run()`. diff --git a/docs/src/content/docs/troubleshooting/known-bugs.mdx b/docs/src/content/docs/troubleshooting/known-bugs.mdx index 3de5beb38fd..1a3b3d1a07f 100644 --- a/docs/src/content/docs/troubleshooting/known-bugs.mdx +++ b/docs/src/content/docs/troubleshooting/known-bugs.mdx @@ -10,9 +10,9 @@ This page lists known cases where InvokeAI does not yet behave as this documenta ### Multi-user mode: boards and workflows cannot be shared -**Documented behavior:** In multi-user mode, a board's owner can set it to **Shared** or **Public** so that other users can view it, and a workflow's owner can mark it as shared so that it appears in every user's workflow library. See [Working with Your Content in Multi-User Mode](/configuration/multi-user-mode/user-guide/#working-with-your-content-in-multi-user-mode). +**Documented behavior:** In multi-user mode, a board's owner can set its general access to **Everyone can view** or **Everyone can contribute**, and a workflow's owner can mark it as shared so that it appears in every user's workflow library. See [Working with Your Content in Multi-User Mode](/configuration/multi-user-mode/user-guide/#working-with-your-content-in-multi-user-mode). -**Current behavior:** v7 has no controls for changing a board's visibility or sharing a workflow. Boards and workflows you create stay private: only you and administrators can see them. +**Current behavior:** v7 has no controls for changing a board's general access or sharing a workflow. Boards and workflows you create stay private: only you and administrators can see them. **Workaround:** None in the v7 interface. Administrators can still view and manage all users' boards, images, and workflows. diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index 79a0cc1855f..20c46bf1d8c 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -4,77 +4,55 @@ specifics so any route can call them after resolving `current_user`. """ +from collections.abc import Iterable + from fastapi import HTTPException from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import ( - BoardRecord, - BoardRecordNotFoundException, - BoardVisibility, +from invokeai.app.services.board_access.board_access_common import ( + BoardAction, + ItemAccessDeniedError, + ItemAction, ) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException from invokeai.app.services.project_records.project_records_common import ProjectRecordNotFoundError -def _get_board_record(board_id: str) -> BoardRecord: - """Get a board record, translating only a confirmed missing board to 404.""" +def _require( + board_id: str, current_user: CurrentUserOrDefault, actions: Iterable[BoardAction], inbox_left_to_storage: bool +) -> None: + """Raise unless the caller may take every action on the board. A board the caller cannot read answers 404, as + a missing one does, so board ids reveal nothing; one they can read but not change answers 403.""" try: - return ApiDependencies.invoker.services.board_records.get(board_id) + access = ApiDependencies.invoker.services.board_access.get( + board_id, current_user.user_id, current_user.is_admin + ) except BoardRecordNotFoundException: raise HTTPException(status_code=404, detail="Board not found") + if not access.allows(BoardAction.Read): + raise HTTPException(status_code=404, detail="Board not found") + allows = access.role_allows if inbox_left_to_storage else access.allows + if not all(allows(action) for action in actions): + raise HTTPException(status_code=403, detail="Not authorized to change this board") -def _board_grants_contribution(board_id: str, current_user: CurrentUserOrDefault) -> bool: - """Whether this user may put media on the board: they own it, or it is Public. - - Shared boards are deliberately excluded — `Shared` grants visibility, not contribution. - Storage errors propagate so a batch cannot silently turn an undecidable permission check into - an ordinary denial. - """ - try: - board = ApiDependencies.invoker.services.board_records.get(board_id) - except BoardRecordNotFoundException: - return False - - return board.user_id == current_user.user_id or board.board_visibility == BoardVisibility.Public - - -def _board_grants_read_access(board_id: str, current_user: CurrentUserOrDefault) -> bool: - """Whether the user may read a board, returning False only when it is confirmed missing or denied.""" - try: - board = ApiDependencies.invoker.services.board_records.get(board_id) - except BoardRecordNotFoundException: - return False - - if board.user_id == current_user.user_id: - return True - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return True - return ApiDependencies.invoker.services.board_records.is_board_shared_with_user(board_id, current_user.user_id) +def assert_board_changes(board_id: str, current_user: CurrentUserOrDefault, actions: Iterable[BoardAction]) -> None: + """Raise unless the caller's role allows every change. What a project's inbox refuses to everyone is left to + the storage, which answers it with a 409 naming the project as the place to make the change.""" + _require(board_id, current_user, actions, inbox_left_to_storage=True) -def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user does not own the image and is not an admin. +def assert_board_read_access(board_id: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may read the board.""" + _require(board_id, current_user, [BoardAction.Read], inbox_left_to_storage=False) - Ownership is satisfied when ANY of these hold: - - The user is an admin. - - The user is the image's direct owner (image_records.user_id). - - The user owns the board the image sits on. - - The image sits on a Public board (public boards grant mutation rights). - """ - if current_user.is_admin: - return - if not ApiDependencies.invoker.services.image_records.exists(image_name): - raise HTTPException(status_code=404, detail="Image not found") - owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) - if owner is not None and owner == current_user.user_id: - return - board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) - if board_id is not None and _board_grants_contribution(board_id, current_user): - return - - raise HTTPException(status_code=403, detail="Not authorized to modify this image") +def assert_board_write_access(board_id: str | None, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may put media on this board. `None` means "no board" and is always allowed, so + upload routes can pass their optional board straight through.""" + if board_id is not None: + _require(board_id, current_user, [BoardAction.AddMedia], inbox_left_to_storage=False) def _assert_image_record_exists(image_name: str) -> None: @@ -104,145 +82,52 @@ def _assert_image_record_exists(image_name: str) -> None: raise HTTPException(status_code=404, detail="Image not found") -def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not view the image. - - Access is granted when ANY of these hold: - - The user is an admin. - - The user owns the image. - - The user owns the board the image sits on. - - The image sits on a shared or public board. - - The image sits on a board explicitly shared with the user. +def assert_image_action(image_name: str, current_user: CurrentUserOrDefault, action: ItemAction) -> None: + """Raise 403 unless the caller may do the action to the image, or 404 when the image is gone. - Board-backed images defer to `assert_board_read_access` so individual image - reads stay consistent with board listings (including board_id="all"). + The refusal stays 403 for an image that exists: a client drops its references to an image it is told is gone, + and a refusal can be reversed (see `_assert_image_record_exists`). """ if current_user.is_admin: return - if not ApiDependencies.invoker.services.image_records.exists(image_name): - raise HTTPException(status_code=404, detail="Image not found") - - owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) - if board_id is not None and _board_grants_read_access(board_id, current_user): - return - - _assert_image_record_exists(image_name) - raise HTTPException(status_code=403, detail="Not authorized to access this image") - - -def assert_board_write_access(board_id: str | None, current_user: CurrentUserOrDefault) -> BoardRecord | None: - """Raise if the current user may not put media on this board. - - `None` means "no board" — always allowed, so upload routes can pass their optional board - straight through. Otherwise access is granted when the user is an admin, owns the board, or - the board is Public (public boards accept contributions from any user). - - Shared boards are deliberately read-only here: `Shared` grants visibility, not contribution. - - Returns the board record it checked (None for no board) so callers that need the board — the - upload routes announce the upload with its visibility — do not look it up a second time. - """ - if board_id is None: - return None - - board = _get_board_record(board_id) - - if current_user.is_admin: - return board - if board.user_id == current_user.user_id: - return board - if board.board_visibility == BoardVisibility.Public: - return board - - raise HTTPException(status_code=403, detail="Not authorized to modify this board") - - -def board_share_recipients(board: BoardRecord | None) -> list[str]: - """The users who can see a private board through an explicit share. - - Empty for no board and for Shared/Public boards, whose uploads are announced to everyone - rather than enumerated. This is how an upload route tells the socket layer which extra - users to notify without the socket layer reaching into storage per event. - """ - if board is None or board.board_visibility is not BoardVisibility.Private: - return [] - - return ApiDependencies.invoker.services.board_records.get_shared_user_ids(board.board_id) - - -def assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not view the video. - - Deliberately identical in shape to `assert_image_read_access`, and delegating to - `assert_board_read_access` for the same reason: the video twin used to inline a narrower rule - that recognized only Shared and Public visibility, so a video on a board explicitly shared with - you was unreadable while the image beside it was fine. Two media kinds on one board should not - disagree about who can see them. - """ - if current_user.is_admin: - return - - owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - if board_id is not None and _board_grants_read_access(board_id, current_user): - return - - if not ApiDependencies.invoker.services.video_records.exists(video_name): - raise HTTPException(status_code=404, detail="Video not found") - raise HTTPException(status_code=403, detail="Not authorized to access this video") - - -def assert_video_owner(video_name: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not mutate the video. - - The mutation twin of `assert_image_owner`: the direct owner, the owner of the board it sits on, - or a Public board, which grants contribution rights. - """ - if current_user.is_admin: - return - - owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name) - if owner is not None and owner == current_user.user_id: - return - - board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - if board_id is not None and _board_grants_contribution(board_id, current_user): - return - - raise HTTPException(status_code=403, detail="Not authorized to modify this video") + try: + ApiDependencies.invoker.services.board_access.require_image( + image_name, current_user.user_id, current_user.is_admin, action + ) + except ItemAccessDeniedError: + _assert_image_record_exists(image_name) + raise HTTPException(status_code=403, detail="Not authorized to access this image") -def assert_board_read_access(board_id: str, current_user: CurrentUserOrDefault) -> None: - """Raise 403 if the current user may not read images from this board. +def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may view the image: their own, or on a board they may read.""" + assert_image_action(image_name, current_user, ItemAction.Read) - Access is granted when ANY of these hold: - - The user is an admin. - - The user owns the board. - - The board visibility is Shared or Public. - - The board is explicitly shared with the user. - """ - board = _get_board_record(board_id) +def assert_video_action(video_name: str, current_user: CurrentUserOrDefault, action: ItemAction) -> None: + """The video twin of `assert_image_action`: two media kinds on one board follow one rule.""" if current_user.is_admin: return + try: + ApiDependencies.invoker.services.board_access.require_video( + video_name, current_user.user_id, current_user.is_admin, action + ) + except ItemAccessDeniedError: + if not ApiDependencies.invoker.services.video_records.exists(video_name): + raise HTTPException(status_code=404, detail="Video not found") + raise HTTPException(status_code=403, detail="Not authorized to access this video") - if board.user_id == current_user.user_id: - return - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return +def assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None: + """Raise unless the caller may view the video: their own, or on a board they may read.""" + assert_video_action(video_name, current_user, ItemAction.Read) - if ApiDependencies.invoker.services.board_records.is_board_shared_with_user(board_id, current_user.user_id): - return - raise HTTPException(status_code=403, detail="Not authorized to access this board") +def item_action_for_changes(changes: object) -> ItemAction: + """Starring someone else's item is the board's managers' to do; any other change to its record is its + owner's.""" + fields = getattr(changes, "model_fields_set", set()) + return ItemAction.Star if fields and fields <= {"starred"} else ItemAction.Edit def assert_project_owned(project_id: str | None, current_user: CurrentUserOrDefault) -> None: diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 1d935992c19..ed5586308c8 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -8,8 +8,12 @@ from invokeai.app.api.routers._access import ( assert_board_write_access as _assert_board_write_access, ) +from invokeai.app.api.routers._access import ( + assert_image_action as _assert_image_action, +) from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE, ImageName +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult @@ -87,11 +91,8 @@ def _remove_from_board_and_classify(image_name: str, old_board_id: str) -> _Scop def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the image. - This is intentionally stricter than _assert_image_owner in images.py: - board ownership is NOT sufficient here. Allowing a user to add someone - else's image to their own board would grant them mutation rights via the - board-ownership fallback in _assert_image_owner, escalating read access - into write access. + Only an image's owner may put it on a board, whatever their role on the board: adding someone else's image to + a board you manage would let you remove and star it there, escalating read access into write access. """ if current_user.is_admin: return @@ -155,7 +156,7 @@ def remove_image_from_board( try: old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" if old_board_id != "none": - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) assert_image_move_maintenance_inactive() removed_images: set[str] = set() failed_images: set[str] = set() @@ -317,7 +318,7 @@ def remove_images_from_board( # letting it escape from inside this handler would replace the 409 with a 500. continue if old_board_id != "none": - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) raise try: @@ -348,10 +349,9 @@ def remove_images_from_board( # The one authorization decision, and it is taken fresh for every name. Memoizing # it per board is tempting — the skip removed the early abort that used to cap an # unauthorized batch at one check — but a cached True outlives the permission it - # recorded: flip a board from Public to Private mid-batch and the rest of the names - # are removed on an answer that is no longer true. _assert_board_write_access reads - # the board record (one indexed SELECT), so re-deciding costs about what the - # get_dto() it replaced cost once. + # recorded: restrict a board's general access mid-batch and the rest of the names + # are removed on an answer that is no longer true. The decision reads the image's + # owner, its board, and that board's access facts (indexed SELECTs). # # Kept out of the try below so that the only way to skip a name for auth is this # branch. Note the two failure modes are not the same: "not allowed" is a skip, @@ -359,7 +359,7 @@ def remove_images_from_board( # could not decide about must be reported rather than silently dropped. if old_board_id != "none": try: - _assert_board_write_access(old_board_id, current_user) + _assert_image_action(image_name, current_user, ItemAction.RemoveFromBoard) except HTTPException: continue except Exception: diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 5baf8a825d7..500c86a3d15 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -6,14 +6,18 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers._access import assert_board_changes as _assert_board_changes from invokeai.app.api.routers._access import assert_board_read_access as _assert_board_read_access from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive +from invokeai.app.services.board_access.board_access_common import ( + BoardAction, + BoardViewer, +) from invokeai.app.services.board_records.board_records_common import ( BoardChanges, BoardRecordInboxException, BoardRecordOrderBy, BoardRecordProjectNotFoundException, - BoardRecordProjectUnavailableException, ) from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.image_records.image_records_common import ImageCategory @@ -66,7 +70,7 @@ def create_board( """Creates a board for the current user, in one of their projects or in the Library""" try: return ApiDependencies.invoker.services.boards.create( - board_name=board_name, user_id=current_user.user_id, project_id=project_id + board_name=board_name, user_id=current_user.user_id, project_id=project_id, viewer=_viewer(current_user) ) except BoardRecordProjectNotFoundException: raise HTTPException(status_code=404, detail="Project not found") @@ -84,11 +88,32 @@ def get_board( _assert_board_read_access(board_id, current_user) try: - return ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + return ApiDependencies.invoker.services.boards.get_dto(board_id=board_id, viewer=_viewer(current_user)) except Exception: raise HTTPException(status_code=404, detail="Board not found") +def _viewer(current_user: CurrentUserOrDefault) -> BoardViewer: + return BoardViewer(user_id=current_user.user_id, is_admin=current_user.is_admin) + + +def _actions_for(changes: BoardChanges) -> list[BoardAction]: + """The board actions a change takes, one per field it sets.""" + actions: list[BoardAction] = [] + if changes.board_name is not None: + actions.append(BoardAction.Rename) + if changes.cover_image_name is not None: + actions.append(BoardAction.SetCover) + if changes.archived is not None: + actions.append(BoardAction.Archive) + if changes.general_access is not None: + actions.append(BoardAction.SetGeneralAccess) + if changes.moves_board: + actions.append(BoardAction.Move) + # An update that names no field still writes the board: it takes an editor, as any change does. + return actions or [BoardAction.Rename] + + @boards_router.patch( "/{board_id}", operation_id="update_board", @@ -105,24 +130,19 @@ def update_board( board_id: str = Path(description="The id of board to update"), changes: BoardChanges = Body(description="The changes to apply to the board"), ) -> BoardDTO: - """Updates a board (user must have access to it). + """Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an + editor; general access and moving between the Library and a project need the owner. - A project's inbox takes its name, archived state and visibility from the project and cannot be - moved, so those changes are refused for it — for admins too; its cover is still fair game. A - board in a project is private and unshared, so a move into a project, or a visibility change on - a member, is refused when the result would be otherwise. The storage decides all of this in one - transaction; the DTO read here is only for the ownership check. + A project's inbox takes its name, archived state and general access from the project and cannot be moved, + so those changes are refused for it — for admins too; its cover is still fair game. The storage decides + that in one transaction. """ - try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - except Exception: - raise HTTPException(status_code=404, detail="Board not found") - - if not current_user.is_admin and board.user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Not authorized to update this board") + _assert_board_changes(board_id, current_user, _actions_for(changes)) try: - return ApiDependencies.invoker.services.boards.update(board_id=board_id, changes=changes) + return ApiDependencies.invoker.services.boards.update( + board_id=board_id, changes=changes, viewer=_viewer(current_user) + ) except BoardRecordInboxException: raise HTTPException( status_code=409, @@ -130,8 +150,6 @@ def update_board( ) except BoardRecordProjectNotFoundException: raise HTTPException(status_code=404, detail="Project not found") - except BoardRecordProjectUnavailableException: - raise HTTPException(status_code=409, detail="Boards in a project must be private and unshared") except Exception: raise HTTPException(status_code=500, detail="Failed to update board") @@ -144,14 +162,8 @@ def delete_board( description="Permanently delete all images and videos on the board", default=False ), ) -> DeleteBoardResult: - """Deletes a board (user must have access to it)""" - try: - board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - except Exception: - raise HTTPException(status_code=404, detail="Board not found") - - if not current_user.is_admin and board.user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Not authorized to delete this board") + """Deletes a board: its owner's, or an admin's, to do""" + _assert_board_changes(board_id, current_user, [BoardAction.Delete]) # Admins delete everything on the board; regular owners only delete their own # contributions so that contributions from other users to a public/shared board @@ -188,7 +200,7 @@ def delete_board( ) if not ApiDependencies.invoker.services.boards.delete_if_unclaimed(board_id=board_id): - # `get_dto` above proved the board existed, so it is either an inbox now or it vanished + # The access check above found the board, so it is either an inbox now or it vanished # in between. Ask again rather than trusting the DTO, which predates any concurrent claim. if board_id in ApiDependencies.invoker.services.board_records.get_inbox_board_ids([board_id]): raise HTTPException( diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 874efd5f424..5c968fe0faa 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -21,18 +21,19 @@ assert_board_write_access as _assert_board_write_access, ) from invokeai.app.api.routers._access import ( - assert_image_owner as _assert_image_owner, + assert_image_action as _assert_image_action, ) from invokeai.app.api.routers._access import ( assert_image_read_access as _assert_image_read_access, ) -from invokeai.app.api.routers._access import assert_project_owned from invokeai.app.api.routers._access import ( - board_share_recipients as _board_share_recipients, + assert_project_owned, + item_action_for_changes, ) from invokeai.app.api.routers._limits import MAX_COPY_BATCH_SIZE from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.invocations.fields import MetadataField +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ( ImageCategory, ImageNamesResult, @@ -64,11 +65,10 @@ # in the videos router. # # "At least one" is doing real work in that sentence. The authorization helpers in _access.py -# short-circuit on the first hit, so an admin or a direct owner costs 0-1 queries per name -- -# but a user reading someone else's Shared/Public board falls all the way through to -# boards.get_dto(), which is six queries including three COUNT aggregates over the board's -# contents. That is the case the bound has to hold, and it is why no route gets a laxer one: -# the oversized selections this cap rejects are split client-side instead. +# short-circuit for an admin (no query) and a direct owner (one), but a user acting on someone +# else's item on a board they share costs three indexed reads per name: the item's owner, its +# board, and that board's access facts. That is the case the bound has to hold, and it is why no +# route gets a laxer one: the oversized selections this cap rejects are split client-side instead. MAX_IMAGE_BATCH_SIZE = 1000 # Names are UUID-derived filenames; the bound only exists to keep a hostile body from # turning into megabytes of SQL parameters. @@ -132,9 +132,7 @@ async def upload_image( ), ) -> ImageDTO: """Uploads an image for the current user""" - # If uploading into a board, verify the user has write access. - # Public boards allow uploads from any authenticated user. - board = await asyncio.to_thread(_assert_board_write_access, board_id, current_user) + await asyncio.to_thread(_assert_board_write_access, board_id, current_user) await asyncio.to_thread(assert_project_owned, project_id, current_user) await asyncio.to_thread(assert_image_move_maintenance_inactive) @@ -207,10 +205,7 @@ def _resize() -> Image.Image: raise HTTPException(status_code=500, detail="Failed to create image") if not is_intermediate and is_gallery_category(image_category): - shared_user_ids = await asyncio.to_thread(_board_share_recipients, board) - ApiDependencies.invoker.services.events.emit_image_uploaded( - image_dto, user_id=current_user.user_id, board=board, shared_user_ids=shared_user_ids - ) + ApiDependencies.invoker.services.events.emit_image_uploaded(image_dto, user_id=current_user.user_id) response.status_code = 201 response.headers["Location"] = image_dto.image_url @@ -241,7 +236,7 @@ def delete_image( image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: """Deletes an image""" - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) assert_image_move_maintenance_inactive() # Let service-level failures surface as errors rather than swallowing them and returning @@ -325,7 +320,7 @@ def update_image( image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), ) -> ImageDTO: """Updates an image""" - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, item_action_for_changes(image_changes)) assert_image_move_maintenance_inactive() try: @@ -576,7 +571,7 @@ def delete_images_from_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) raise try: @@ -599,7 +594,7 @@ def delete_images_from_list( # only the delete lost the race. board_id: str | None = None try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) board_id = image_dto.board_id or "none" ApiDependencies.invoker.services.images.delete(image_name) @@ -666,7 +661,7 @@ def delete_uncategorized_images( affected_boards: set[str] = set() for image_name in image_names: try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Delete) ApiDependencies.invoker.services.images.delete(image_name) deleted_images.add(image_name) affected_boards.add("none") @@ -760,7 +755,7 @@ def star_images_in_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) raise try: @@ -777,7 +772,7 @@ def star_images_in_list( # twice and could land in both starred_images and failed_images. for image_name in dict.fromkeys(image_names): try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) updated_image_dto = ApiDependencies.invoker.services.images.update( image_name, changes=ImageRecordChanges(starred=True) ) @@ -818,7 +813,7 @@ def unstar_images_in_list( assert_image_move_maintenance_inactive() except HTTPException: for image_name in image_names: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) raise try: @@ -829,7 +824,7 @@ def unstar_images_in_list( affected_boards: set[str] = set() for image_name in dict.fromkeys(image_names): try: - _assert_image_owner(image_name, current_user) + _assert_image_action(image_name, current_user, ItemAction.Star) updated_image_dto = ApiDependencies.invoker.services.images.update( image_name, changes=ImageRecordChanges(starred=False) ) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 70bfa3456bc..e55e01731ab 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -27,19 +27,20 @@ from invokeai.app.api.routers._access import ( assert_board_write_access as _assert_board_write_access, ) -from invokeai.app.api.routers._access import assert_project_owned from invokeai.app.api.routers._access import ( - assert_video_owner as _assert_video_owner, + assert_project_owned, + item_action_for_changes, ) from invokeai.app.api.routers._access import ( - assert_video_read_access as _assert_video_read_access, + assert_video_action as _assert_video_action, ) from invokeai.app.api.routers._access import ( - board_share_recipients as _board_share_recipients, + assert_video_read_access as _assert_video_read_access, ) from invokeai.app.api.routers._limits import MAX_COPY_BATCH_SIZE from invokeai.app.api.routers.images import WorkflowAndGraphResponse from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator +from invokeai.app.services.board_access.board_access_common import ItemAction from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin, is_gallery_category from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE, OffsetPaginatedResults, SQLiteDirection from invokeai.app.services.video_records.video_records_common import ( @@ -152,10 +153,8 @@ def _get_video_cache_control() -> str: def _assert_video_direct_owner(video_name: str, current_user: CurrentUserOrDefault) -> None: """Raise 403 if the current user is not the direct owner of the video. - Intentionally stricter than _assert_video_owner: board-ownership and public-board - fallbacks are NOT honored. Mirrors _assert_image_direct_owner in board_images.py — - board-move operations need to verify the *original* owner, otherwise a user could - move someone else's video onto their own board via the board-owner branch. + Mirrors _assert_image_direct_owner in board_images.py: only a video's owner may put it on a board, whatever + their role on the board, otherwise a user could move someone else's video onto a board they manage. """ if current_user.is_admin: return @@ -443,7 +442,7 @@ async def ingest_uploaded_video( and the concurrency slots are enforced there, before this runs. """ # Check board access for uploads to a specific board. - board = await run_in_threadpool(_assert_board_write_access, board_id, current_user) + await run_in_threadpool(_assert_board_write_access, board_id, current_user) await run_in_threadpool(assert_project_owned, project_id, current_user) # Stream the upload straight into a tmp file so we can probe it and then hand its path @@ -553,10 +552,7 @@ async def ingest_uploaded_video( raise HTTPException(status_code=500, detail="Failed to create video") if not is_intermediate and is_gallery_category(video_category): - shared_user_ids = await run_in_threadpool(_board_share_recipients, board) - ApiDependencies.invoker.services.events.emit_video_uploaded( - video_dto, user_id=current_user.user_id, board=board, shared_user_ids=shared_user_ids - ) + ApiDependencies.invoker.services.events.emit_video_uploaded(video_dto, user_id=current_user.user_id) return video_dto finally: @@ -621,7 +617,7 @@ def delete_video( current_user: CurrentUserOrDefault, video_name: str = PathParam(description="The name of the video to delete"), ) -> DeleteVideosResult: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) # Let service-level failures surface as 500s rather than swallowing them and returning a # success-shaped response. A previous version of this handler caught everything and @@ -668,7 +664,7 @@ def delete_videos_from_list( # in both deleted_videos and failed_videos. for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name) board_id = video_dto.board_id or "none" ApiDependencies.invoker.services.videos.delete(video_name) @@ -704,7 +700,7 @@ def delete_uncategorized_videos( affected_boards: set[str] = set() for video_name in names_result.video_names: try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Delete) ApiDependencies.invoker.services.videos.delete(video_name) deleted_videos.add(video_name) affected_boards.add("none") @@ -728,7 +724,7 @@ def update_video( video_name: str = PathParam(description="The name of the video to update"), video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"), ) -> VideoDTO: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, item_action_for_changes(video_changes)) try: return ApiDependencies.invoker.services.videos.update(video_name, video_changes) except Exception: @@ -1124,7 +1120,7 @@ def star_videos_in_list( affected_boards: set[str] = set() for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Star) updated = ApiDependencies.invoker.services.videos.update( video_name, changes=VideoRecordChanges(starred=True) ) @@ -1152,7 +1148,7 @@ def unstar_videos_in_list( affected_boards: set[str] = set() for video_name in dict.fromkeys(batch.video_names): try: - _assert_video_owner(video_name, current_user) + _assert_video_action(video_name, current_user, ItemAction.Star) updated = ApiDependencies.invoker.services.videos.update( video_name, changes=VideoRecordChanges(starred=False) ) @@ -1211,19 +1207,10 @@ def remove_video_from_board( current_user: CurrentUserOrDefault, video_name: str = Body(description="The name of the video to remove from its board", embed=True), ) -> RemoveVideosFromBoardResult: - # A video association can be removed by EITHER the direct video owner OR a user with - # write access to the destination board (admin, board owner, or any contributor when the - # board is Public). This mirrors remove_image_from_board and prevents a video from being - # stranded when a non-owner uploads into a Public board that is later made Shared/Private: - # without the board-write fallback, neither the uploader nor the board owner could - # detach the video. See PR #9163 review. + # The video's owner may always take it off a board; anyone else needs to manage the board's items, as for + # images. old_board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name) - try: - _assert_video_direct_owner(video_name, current_user) - except HTTPException: - if old_board_id is None: - raise - _assert_board_write_access(old_board_id, current_user) + _assert_video_action(video_name, current_user, ItemAction.RemoveFromBoard) try: ApiDependencies.invoker.services.board_video_records.remove_video_from_board(video_name=video_name) return RemoveVideosFromBoardResult( diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 4ddbf95408e..709a3d8dee1 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -9,7 +9,6 @@ from starlette.types import Message, Receive, Scope, Send from invokeai.app.services.auth.token_service import verify_token -from invokeai.app.services.board_records.board_records_common import BoardVisibility from invokeai.app.services.config.config_default import get_config from invokeai.app.services.events.events_common import ( BatchEnqueuedEvent, @@ -1255,11 +1254,9 @@ async def _handle_intermediates_event(self, event: FastAPIEvent[IntermediatesEve async def _handle_media_event(self, event: FastAPIEvent[MediaUploadedEventBase]) -> None: """Route an upload to the clients whose gallery can show it. - Shared and Public boards are visible to every user, so those uploads go to everyone. - Anything else — a private board or no board at all — goes to the uploader, the board's - owner (an admin may upload into someone else's private board), the users that board is - explicitly shared with, and admins, who see every board. The route resolves the share - list, so routing an event stays free of storage access. + The board access service says who sees the board: everyone, when its general access is not restricted, or + else its owner and members. The uploader and admins, who see every board, always get it, and so does + everyone involved when the media landed on no board or on one deleted meanwhile. """ event_name, event_data = event payload = event_data.model_dump(mode="json") @@ -1268,16 +1265,25 @@ async def _handle_media_event(self, event: FastAPIEvent[MediaUploadedEventBase]) await self._sio.emit(event=event_name, data=payload, room="admin") return - if event_data.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): + from invokeai.app.api.dependencies import ApiDependencies + + audience = None + if event_data.board_id is not None: + try: + audience = await run_in_threadpool( + ApiDependencies.invoker.services.board_access.audience, event_data.board_id + ) + except Exception: + # The uploader and admins still hear of it; nobody else is told of a board they may not see. + logger.exception(f"Could not tell who sees board {event_data.board_id}; announcing the upload narrowly") + if audience is not None and audience.everyone: await self._sio.emit(event=event_name, data=payload) return rooms = [f"user:{event_data.user_id}", "admin"] - if event_data.board_owner_id is not None and event_data.board_owner_id != event_data.user_id: - rooms.append(f"user:{event_data.board_owner_id}") - # A room the socket is already in costs nothing: python-socketio unions a room list by sid. - rooms.extend(f"user:{user_id}" for user_id in event_data.shared_user_ids) - await self._sio.emit(event=event_name, data=payload, room=rooms) + if audience is not None: + rooms.extend(f"user:{user_id}" for user_id in audience.user_ids) + await self._sio.emit(event=event_name, data=payload, room=list(dict.fromkeys(rooms))) async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None: event_name, event_data = event diff --git a/invokeai/app/services/board_access/board_access_common.py b/invokeai/app/services/board_access/board_access_common.py new file mode 100644 index 00000000000..fd8ec6ad39e --- /dev/null +++ b/invokeai/app/services/board_access/board_access_common.py @@ -0,0 +1,169 @@ +"""Who may do what on a board: roles, the actions they allow, and the one table that maps them.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class BoardRole(str, Enum): + """A caller's role on a board. Each role allows everything the roles before it allow.""" + + Viewer = "viewer" + Contributor = "contributor" + Editor = "editor" + Owner = "owner" + + +class GeneralAccess(str, Enum): + """The role every other account gets on a board: none, viewer or contributor.""" + + Restricted = "restricted" + Viewer = "viewer" + Contributor = "contributor" + + +class BoardAction(str, Enum): + """What a caller can do to a board as a whole.""" + + Read = "read" + """Browse the board, open, download and copy out its items, and use them in one's own generations.""" + AddMedia = "add_media" + """Upload to the board, generate into it, and move or copy one's own items onto it.""" + ManageItems = "manage_items" + """Remove anyone's items from the board, and star or unstar them.""" + Rename = "rename" + SetCover = "set_cover" + Archive = "archive" + SetGeneralAccess = "set_general_access" + Move = "move" + """Move the board between the Library and a project.""" + Delete = "delete" + + +class ItemAction(str, Enum): + """What a caller can do to an image or a video. An item's owner may do all of it.""" + + Read = "read" + RemoveFromBoard = "remove_from_board" + Star = "star" + """Star or unstar: a flag every viewer of the item sees.""" + Edit = "edit" + """Change the item's record otherwise (its category, whether it is an intermediate).""" + Delete = "delete" + """Delete the item permanently. Only its owner and admins may: a board's managers can only remove it.""" + + +_RANK = {BoardRole.Viewer: 1, BoardRole.Contributor: 2, BoardRole.Editor: 3, BoardRole.Owner: 4} + +MINIMUM_ROLE: dict[BoardAction, BoardRole] = { + BoardAction.Read: BoardRole.Viewer, + BoardAction.AddMedia: BoardRole.Contributor, + BoardAction.ManageItems: BoardRole.Editor, + BoardAction.Rename: BoardRole.Editor, + BoardAction.SetCover: BoardRole.Editor, + BoardAction.Archive: BoardRole.Editor, + BoardAction.SetGeneralAccess: BoardRole.Owner, + BoardAction.Move: BoardRole.Owner, + BoardAction.Delete: BoardRole.Owner, +} +"""The least role each board action needs: the only copy of who may do what.""" + +# A project's inbox is managed through its project, by everyone, admins included. +_MANAGED_BY_PROJECT = frozenset( + {BoardAction.Rename, BoardAction.Archive, BoardAction.SetGeneralAccess, BoardAction.Move, BoardAction.Delete} +) + +# The board action that lets a caller do an item action to someone else's item. Deleting someone else's item +# permanently is for admins alone. +_BOARD_ACTION_FOR_ITEM: dict[ItemAction, Optional[BoardAction]] = { + ItemAction.Read: BoardAction.Read, + ItemAction.RemoveFromBoard: BoardAction.ManageItems, + ItemAction.Star: BoardAction.ManageItems, + ItemAction.Edit: None, + ItemAction.Delete: None, +} + + +def role_of( + *, owner_id: str, user_id: str, member_role: Optional[BoardRole], general_access: GeneralAccess +) -> Optional[BoardRole]: + """The highest role a caller has on a board: as its owner, as a member, or through general access.""" + if owner_id == user_id: + return BoardRole.Owner + candidates = [member_role] if member_role is not None else [] + if general_access is not GeneralAccess.Restricted: + candidates.append(BoardRole(general_access.value)) + return max(candidates, key=_RANK.__getitem__, default=None) + + +@dataclass(frozen=True) +class BoardAccess: + """A caller's access to one board.""" + + role: Optional[BoardRole] + """The caller's own role, or None when only admin rights give access.""" + is_admin: bool + is_inbox: bool + + @property + def admin_override(self) -> bool: + """Whether admin rights give the caller more than their own role does.""" + return self.is_admin and self.role is not BoardRole.Owner + + def allows(self, action: BoardAction) -> bool: + return not (self.is_inbox and action in _MANAGED_BY_PROJECT) and self.role_allows(action) + + def role_allows(self, action: BoardAction) -> bool: + """Whether the caller's role, or admin rights, allow the action, setting aside what a project's inbox + reserves to its project: for a caller that leaves that refusal to the storage.""" + if self.is_admin: + return True + return self.role is not None and _RANK[self.role] >= _RANK[MINIMUM_ROLE[action]] + + def allowed_actions(self) -> list[BoardAction]: + return [action for action in BoardAction if self.allows(action)] + + +def item_allows(*, is_item_owner: bool, is_admin: bool, board: Optional[BoardAccess], action: ItemAction) -> bool: + """Whether a caller may do an item action, given the access they have to the board the item is on (None when + it is on none).""" + if is_item_owner or is_admin: + return True + board_action = _BOARD_ACTION_FOR_ITEM[action] + return board is not None and board_action is not None and board.allows(board_action) + + +@dataclass(frozen=True) +class BoardViewer: + """The account a board is described to.""" + + user_id: str + is_admin: bool + + +@dataclass(frozen=True) +class BoardAudience: + """Who sees what lands on a board: everyone, or its owner and members (admins see every board).""" + + everyone: bool + user_ids: tuple[str, ...] + + +class BoardAccessDeniedError(Exception): + """The caller may not do the action. `readable` tells a board they cannot see (answered as missing) from one + they can see but not change.""" + + def __init__(self, board_id: str, action: BoardAction, readable: bool) -> None: + super().__init__(f"Not allowed to {action.value} on board {board_id}") + self.board_id = board_id + self.action = action + self.readable = readable + + +class ItemAccessDeniedError(Exception): + """The caller may not do the action to the item.""" + + def __init__(self, item_name: str, action: ItemAction) -> None: + super().__init__(f"Not allowed to {action.value} {item_name}") + self.item_name = item_name + self.action = action diff --git a/invokeai/app/services/board_access/board_access_default.py b/invokeai/app/services/board_access/board_access_default.py new file mode 100644 index 00000000000..1e08e2c49f2 --- /dev/null +++ b/invokeai/app/services/board_access/board_access_default.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from invokeai.app.services.board_access.board_access_common import ( + BoardAccess, + BoardAccessDeniedError, + BoardAction, + BoardAudience, + BoardRole, + GeneralAccess, + ItemAccessDeniedError, + ItemAction, + item_allows, + role_of, +) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.queries import Queries + +if TYPE_CHECKING: + from invokeai.app.services.invoker import Invoker + + +class BoardAccessService: + """Decides what a caller may do to a board and to the images and videos on it. + + Routers, graph execution, workflow-call expansion and socket routing all ask here, so the rules in + `board_access_common` are applied the same way everywhere. Listings decide which boards a caller sees in SQL + (`queries/board_access.readable_board`), by the same rules. + """ + + __invoker: Invoker + + def __init__(self, database: Database) -> None: + self._database = database + + @property + def _queries(self) -> Queries: + # Looked up on use: `InvocationServices` builds this service for every caller, some of them with no + # database to query. + return self._database.queries + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def get(self, board_id: str, user_id: str, is_admin: bool) -> BoardAccess: + """The caller's access to the board. Raises `BoardRecordNotFoundException` when there is no such board.""" + facts = self._queries.boards.access_facts(board_id, user_id) + if facts is None: + raise BoardRecordNotFoundException + owner_id, general_access, member_role, is_inbox = facts + role = role_of( + owner_id=owner_id, + user_id=user_id, + member_role=BoardRole(member_role) if member_role is not None else None, + general_access=GeneralAccess(general_access), + ) + return BoardAccess(role=role, is_admin=is_admin, is_inbox=is_inbox) + + def require(self, board_id: str, user_id: str, is_admin: bool, action: BoardAction) -> BoardAccess: + """The caller's access, when it allows the action. Raises `BoardAccessDeniedError` otherwise, and + `BoardRecordNotFoundException` when there is no such board.""" + access = self.get(board_id, user_id, is_admin) + if not access.allows(action): + raise BoardAccessDeniedError(board_id, action, readable=access.allows(BoardAction.Read)) + return access + + def member_roles(self, board_ids: list[str], user_id: str) -> dict[str, BoardRole]: + """The caller's membership role on each of these boards it is a member of, in one query.""" + return { + board_id: BoardRole(role) + for board_id, role in self._queries.boards.member_roles(board_ids, user_id).items() + } + + def require_image(self, image_name: str, user_id: str, is_admin: bool, action: ItemAction) -> None: + """Raises `ItemAccessDeniedError` unless the caller may do the action to the image.""" + if is_admin: + return + services = self.__invoker.services + is_owner = services.image_records.get_user_id(image_name) == user_id + board_id = None if is_owner else services.board_image_records.get_board_for_image(image_name) + self._require_item(image_name, board_id, user_id, is_admin, is_owner, action) + + def require_video(self, video_name: str, user_id: str, is_admin: bool, action: ItemAction) -> None: + """Raises `ItemAccessDeniedError` unless the caller may do the action to the video.""" + if is_admin: + return + services = self.__invoker.services + is_owner = services.video_records.get_user_id(video_name) == user_id + board_id = None if is_owner else services.board_video_records.get_board_for_video(video_name) + self._require_item(video_name, board_id, user_id, is_admin, is_owner, action) + + def _require_item( + self, + item_name: str, + board_id: Optional[str], + user_id: str, + is_admin: bool, + is_owner: bool, + action: ItemAction, + ) -> None: + board: Optional[BoardAccess] = None + if board_id is not None: + try: + board = self.get(board_id, user_id, is_admin) + except BoardRecordNotFoundException: + board = None + if not item_allows(is_item_owner=is_owner, is_admin=is_admin, board=board, action=action): + raise ItemAccessDeniedError(item_name, action) + + def audience(self, board_id: str) -> Optional[BoardAudience]: + """Who sees what lands on the board, or None when there is no such board.""" + record = self._queries.boards.get(board_id) + if record is None: + return None + if GeneralAccess(record.general_access) is not GeneralAccess.Restricted: + return BoardAudience(everyone=True, user_ids=()) + members = self._queries.boards.shared_user_ids(board_id) + return BoardAudience(everyone=False, user_ids=(record.user_id, *members)) diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 07dbb6fe037..dbba7da1d86 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -64,23 +64,11 @@ def update( Raises: BoardRecordInboxException: The board is a project's inbox and the changes rename, - archive, publish or move it. Only the cover may change through here. + archive, change the general access of or move it. Only the cover may change through here. BoardRecordProjectNotFoundException: The destination is not one of the owner's projects. - BoardRecordProjectUnavailableException: The board would be a non-private or explicitly - shared member of a project. """ pass - @abstractmethod - def is_board_shared_with_user(self, board_id: str, user_id: str) -> bool: - """Checks whether a board has been explicitly shared with a specific user.""" - pass - - @abstractmethod - def get_shared_user_ids(self, board_id: str) -> list[str]: - """Gets the ids of the users a board has been explicitly shared with.""" - pass - @abstractmethod def get_many( self, diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index a487b30dbb7..bafa1af115c 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -2,22 +2,31 @@ from enum import Enum from typing import Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator +from invokeai.app.services.board_access.board_access_common import GeneralAccess from invokeai.app.util.metaenum import MetaEnum from invokeai.app.util.misc import get_iso_timestamp from invokeai.app.util.model_exclude_null import BaseModelExcludeNull class BoardVisibility(str, Enum, metaclass=MetaEnum): - """The visibility options for a board.""" + """Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients.""" Private = "private" - """Only the board owner (and admins) can see and modify this board.""" + """General access `restricted`.""" Shared = "shared" - """All users can view this board, but only the owner (and admins) can modify it.""" + """General access `viewer`.""" Public = "public" - """All users can view this board; only the owner (and admins) can modify its structure.""" + """General access `contributor`.""" + + +GENERAL_ACCESS_BY_VISIBILITY = { + BoardVisibility.Private: GeneralAccess.Restricted, + BoardVisibility.Shared: GeneralAccess.Viewer, + BoardVisibility.Public: GeneralAccess.Contributor, +} +VISIBILITY_BY_GENERAL_ACCESS = {access: visibility for visibility, access in GENERAL_ACCESS_BY_VISIBILITY.items()} class BoardRecord(BaseModelExcludeNull): @@ -39,16 +48,16 @@ class BoardRecord(BaseModelExcludeNull): """The name of the cover image of the board.""" archived: bool = Field(description="Whether or not the board is archived.") """Whether or not the board is archived.""" - board_visibility: BoardVisibility = Field( - default=BoardVisibility.Private, description="The visibility of the board." + general_access: GeneralAccess = Field( + default=GeneralAccess.Restricted, + description="The role every other account gets on the board: none (restricted), viewer or contributor.", ) - """The visibility of the board (private, shared, or public).""" project_id: Optional[str] = Field( default=None, description="The id of the owner's project this board belongs to; absent for a Library board." ) """A board lives in exactly one of its owner's projects, or in the Library when this is `None`. - Project boards are private and unshared. Clients must treat the field as absent-or-null: the - record's own `model_dump` drops `None` values, but a route's response serialization need not.""" + Clients must treat the field as absent-or-null: the record's own `model_dump` drops `None` values, + but a route's response serialization need not.""" def deserialize_board_record(board_dict: dict) -> BoardRecord: @@ -66,11 +75,10 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: deleted_at = board_dict.get("deleted_at", get_iso_timestamp()) archived = board_dict.get("archived", False) project_id = board_dict.get("project_id") - board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value) try: - board_visibility = BoardVisibility(board_visibility_raw) + general_access = GeneralAccess(board_dict.get("general_access", GeneralAccess.Restricted.value)) except ValueError: - board_visibility = BoardVisibility.Private + general_access = GeneralAccess.Restricted return BoardRecord( board_id=board_id, @@ -81,7 +89,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: updated_at=updated_at, deleted_at=deleted_at, archived=archived, - board_visibility=board_visibility, + general_access=general_access, project_id=project_id, ) @@ -103,7 +111,14 @@ class BoardChanges(BaseModel, extra="forbid"): default=None, max_length=255, description="The name of the board's new cover image." ) archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived") - board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.") + general_access: Optional[GeneralAccess] = Field( + default=None, description="The role every other account gets on the board. Only its owner may change it." + ) + board_visibility: Optional[BoardVisibility] = Field( + default=None, + description="Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`.", + json_schema_extra={"deprecated": True}, + ) project_id: Optional[str] = Field( default=None, description=( @@ -112,6 +127,16 @@ class BoardChanges(BaseModel, extra="forbid"): ), ) + @model_validator(mode="after") + def _visibility_names_general_access(self) -> "BoardChanges": + """`board_visibility` is `general_access` under its old name: only one of them may be sent.""" + if self.board_visibility is not None: + if self.general_access is not None: + raise ValueError("Send general_access or board_visibility, not both") + self.general_access = GENERAL_ACCESS_BY_VISIBILITY[self.board_visibility] + self.board_visibility = None + return self + @property def moves_board(self) -> bool: """Whether the request names a destination at all; `None` is the Library, absent is "stay".""" @@ -155,14 +180,3 @@ class BoardRecordProjectNotFoundException(BoardRecordSaveException): def __init__(self, message="Project not found"): super().__init__(message) - - -class BoardRecordProjectUnavailableException(BoardRecordSaveException): - """Raised when a board cannot be in a project: it is shared or public, or would become so. - - Boards in a project are private and unshared, so a visibility change and a move are refused - together whenever the result would be a non-private project board. - """ - - def __init__(self, message="Boards in a project must be private"): - super().__init__(message) diff --git a/invokeai/app/services/board_records/board_records_default.py b/invokeai/app/services/board_records/board_records_default.py index 593eda740f2..f7489df8845 100644 --- a/invokeai/app/services/board_records/board_records_default.py +++ b/invokeai/app/services/board_records/board_records_default.py @@ -8,9 +8,7 @@ BoardRecordNotFoundException, BoardRecordOrderBy, BoardRecordProjectNotFoundException, - BoardRecordProjectUnavailableException, BoardRecordSaveException, - BoardVisibility, ) from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import Queries @@ -74,12 +72,6 @@ def get( raise BoardRecordNotFoundException return board - def is_board_shared_with_user(self, board_id: str, user_id: str) -> bool: - return self._queries.boards.is_shared_with(board_id, user_id) - - def get_shared_user_ids(self, board_id: str) -> list[str]: - return self._queries.boards.shared_user_ids(board_id) - def update( self, board_id: str, @@ -102,16 +94,10 @@ def apply(q: Queries) -> Optional[BoardRecord]: if inbox_project is not None and ( changes.board_name is not None or changes.archived is not None - or changes.board_visibility is not None + or changes.general_access is not None or changes.moves_board ): raise BoardRecordInboxException - next_project = changes.project_id if changes.moves_board else current.project_id - next_visibility = changes.board_visibility or current.board_visibility - if next_project is not None and ( - next_visibility != BoardVisibility.Private or q.boards.is_shared(board_id) - ): - raise BoardRecordProjectUnavailableException if not destination_exists: raise BoardRecordProjectNotFoundException if not q.boards.update(board_id, changes): diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py index 496ba5f29cd..850760ba69d 100644 --- a/invokeai/app/services/boards/boards_base.py +++ b/invokeai/app/services/boards/boards_base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Optional +from invokeai.app.services.board_access.board_access_common import BoardViewer from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.shared.pagination import OffsetPaginatedResults, SQLiteDirection @@ -15,16 +16,20 @@ def create( board_name: str, user_id: str, project_id: Optional[str] = None, + *, + viewer: BoardViewer, ) -> BoardDTO: - """Creates a board for a specific user, in one of their projects or in the Library.""" + """Creates a board for a specific user, in one of their projects or in the Library, described to the + viewer (their role, allowed actions, the owner's name).""" pass @abstractmethod def get_dto( self, board_id: str, + viewer: BoardViewer, ) -> BoardDTO: - """Gets a board.""" + """Gets a board, described to the viewer.""" pass @abstractmethod @@ -32,8 +37,9 @@ def update( self, board_id: str, changes: BoardChanges, + viewer: BoardViewer, ) -> BoardDTO: - """Updates a board.""" + """Updates a board, returning it described to the viewer.""" pass @abstractmethod diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 7a1ceda82eb..1bbbdad5534 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -2,7 +2,12 @@ from pydantic import Field -from invokeai.app.services.board_records.board_records_common import BoardRecord +from invokeai.app.services.board_access.board_access_common import BoardAction, BoardRole +from invokeai.app.services.board_records.board_records_common import ( + VISIBILITY_BY_GENERAL_ACCESS, + BoardRecord, + BoardVisibility, +) class BoardDTO(BoardRecord): @@ -26,8 +31,27 @@ class BoardDTO(BoardRecord): """Uploaded videos are assets ('user' category) while generated videos are 'general', mirroring images. `video_count` stays the total so "delete board with N videos" copy remains honest; this field lets clients split the total across the Media/Assets views.""" - owner_username: Optional[str] = Field(default=None, description="The username of the board owner (for admin view).") - """The username of the board owner (for admin view).""" + owner_username: Optional[str] = Field( + default=None, + description=( + "The board owner's display name. Admins get the email of an owner without one; other callers get" + " 'Unknown user'. Null when the owner's account no longer exists." + ), + ) + board_visibility: BoardVisibility = Field( + default=BoardVisibility.Private, + description="Deprecated, to be removed in 8.0: `general_access` under its old name.", + json_schema_extra={"deprecated": True}, + ) + my_role: Optional[BoardRole] = Field( + default=None, description="The caller's own role on the board; null when only admin rights give access." + ) + allowed_actions: list[BoardAction] = Field( + default_factory=list, description="The board actions the caller may take. Gate interfaces on these." + ) + admin_override: bool = Field( + default=False, description="Whether admin rights give the caller more than `my_role` does." + ) is_inbox: bool = Field( default=False, description="Whether this board is its project's inbox, which only the project APIs may change." ) @@ -49,9 +73,11 @@ def board_record_to_dto( asset_video_count: int = 0, is_inbox: bool = False, ) -> BoardDTO: - """Converts a board record to a board DTO.""" + """Converts a board record to a board DTO, describing it to no one in particular: `BoardService` adds what + depends on the caller.""" return BoardDTO( **board_record.model_dump(exclude={"cover_image_name"}), + board_visibility=VISIBILITY_BY_GENERAL_ACCESS[board_record.general_access], cover_image_name=cover_image_name, cover_video_name=cover_video_name, image_count=image_count, diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 9ebf1098697..d8dd01c00dd 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -1,5 +1,6 @@ from typing import Optional +from invokeai.app.services.board_access.board_access_common import BoardAccess, BoardRole, BoardViewer, role_of from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, BoardRecordOrderBy from invokeai.app.services.boards.boards_base import BoardServiceABC from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto @@ -49,18 +50,19 @@ def create( board_name: str, user_id: str, project_id: Optional[str] = None, + *, + viewer: BoardViewer, ) -> BoardDTO: board_record = self.__invoker.services.board_records.save(board_name, user_id, project_id) - return board_record_to_dto(board_record, None, 0, 0) + return self._describe([board_record_to_dto(board_record, None, 0, 0)], viewer)[0] - def get_dto(self, board_id: str) -> BoardDTO: - # One query for the record and the project it is the inbox of. `get_dto` is what every - # authorization check resolves through, so a second lookup here is a second transaction — - # and the lock it takes — on the most-travelled read in the API. + def get_dto(self, board_id: str, viewer: BoardViewer) -> BoardDTO: + # One query for the record and the project it is the inbox of: a second lookup would be a second + # transaction, and the lock it takes, on a read every board route makes. board_record, inbox_of_project_id = self.__invoker.services.board_records.get_with_inbox_project(board_id) cover_image_name, cover_video_name = self._resolve_cover(board_record.board_id) image_count, video_count, asset_count, asset_video_count = self._get_counts(board_id) - return board_record_to_dto( + dto = board_record_to_dto( board_record, cover_image_name, image_count, @@ -70,17 +72,14 @@ def get_dto(self, board_id: str) -> BoardDTO: asset_video_count=asset_video_count, is_inbox=inbox_of_project_id is not None, ) + return self._describe([dto], viewer)[0] - def update( - self, - board_id: str, - changes: BoardChanges, - ) -> BoardDTO: + def update(self, board_id: str, changes: BoardChanges, viewer: BoardViewer) -> BoardDTO: self.__invoker.services.board_records.update(board_id, changes) # Re-read through `get_dto` rather than shaping the update's own return: it is the one # place that resolves cover, counts and the inbox flag together, and an update that # dropped the flag would tell the gallery a project's inbox is an ordinary board. - return self.get_dto(board_id) + return self.get_dto(board_id, viewer) def delete_if_unclaimed(self, board_id: str) -> bool: return self.__invoker.services.board_records.delete_if_unclaimed(board_id) @@ -98,7 +97,7 @@ def get_many( board_records = self.__invoker.services.board_records.get_many( user_id, is_admin, order_by, direction, offset, limit, include_archived ) - board_dtos = self._to_dtos(board_records.items, is_admin) + board_dtos = self._to_dtos(board_records.items, BoardViewer(user_id=user_id, is_admin=is_admin)) return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) @@ -113,46 +112,63 @@ def get_all( board_records = self.__invoker.services.board_records.get_all( user_id, is_admin, order_by, direction, include_archived ) - return self._to_dtos(board_records, is_admin) - - def _to_dtos(self, board_records: list[BoardRecord], is_admin: bool) -> list[BoardDTO]: - """Builds board DTOs for a listing with a fixed number of queries. + return self._to_dtos(board_records, BoardViewer(user_id=user_id, is_admin=is_admin)) - Both the media summaries and (for admins) the owner display names are fetched for - the whole page at once. The owner lookup used to run one `users.get` per board, so - an admin listing 50 boards issued 50 extra queries for what is usually a handful of - distinct owners. - """ + def _to_dtos(self, board_records: list[BoardRecord], viewer: BoardViewer) -> list[BoardDTO]: + """Builds the board DTOs of a listing with a fixed number of queries: the media summaries and inbox flags + for the whole page at once, then what `_describe` adds.""" summaries = self.__invoker.services.gallery.get_board_media_summaries( [record.board_id for record in board_records] ) inbox_board_ids = self.__invoker.services.board_records.get_inbox_board_ids( [record.board_id for record in board_records] ) - owners = ( - self.__invoker.services.users.get_many([record.user_id for record in board_records]) if is_admin else {} - ) - - board_dtos: list[BoardDTO] = [] - for r in board_records: - summary = summaries[r.board_id] - - # For admin users, include owner username - owner = owners.get(r.user_id) - owner_username = (owner.display_name or owner.email) if owner else None - - board_dtos.append( - board_record_to_dto( - r, - summary.cover_image_name, - summary.image_count, - summary.asset_count, - owner_username, - cover_video_name=summary.cover_video_name, - video_count=summary.video_count, - asset_video_count=summary.asset_video_count, - is_inbox=r.board_id in inbox_board_ids, + dtos = [ + board_record_to_dto( + r, + summaries[r.board_id].cover_image_name, + summaries[r.board_id].image_count, + summaries[r.board_id].asset_count, + cover_video_name=summaries[r.board_id].cover_video_name, + video_count=summaries[r.board_id].video_count, + asset_video_count=summaries[r.board_id].asset_video_count, + is_inbox=r.board_id in inbox_board_ids, + ) + for r in board_records + ] + return self._describe(dtos, viewer) + + def _describe(self, dtos: list[BoardDTO], viewer: BoardViewer) -> list[BoardDTO]: + """Adds what depends on who is looking: their role and allowed actions, the owner's name, and the project, + which only the owner and admins learn. Two queries for any number of boards.""" + board_ids = [dto.board_id for dto in dtos] + member_roles = self.__invoker.services.board_access.member_roles(board_ids, viewer.user_id) + owners = self.__invoker.services.users.get_many([dto.user_id for dto in dtos]) + described: list[BoardDTO] = [] + for dto in dtos: + role = role_of( + owner_id=dto.user_id, + user_id=viewer.user_id, + member_role=member_roles.get(dto.board_id), + general_access=dto.general_access, + ) + access = BoardAccess(role=role, is_admin=viewer.is_admin, is_inbox=dto.is_inbox) + owner = owners.get(dto.user_id) + owner_username = None + if owner is not None: + # An email only for admins and the owner themselves. + sees_email = viewer.is_admin or owner.user_id == viewer.user_id + owner_username = owner.display_name or (owner.email if sees_email else "Unknown user") + sees_project = viewer.is_admin or role is BoardRole.Owner + described.append( + dto.model_copy( + update={ + "my_role": role, + "allowed_actions": access.allowed_actions(), + "admin_override": access.admin_override, + "owner_username": owner_username, + "project_id": dto.project_id if sees_project else None, + } ) ) - - return board_dtos + return described diff --git a/invokeai/app/services/events/events_base.py b/invokeai/app/services/events/events_base.py index 95f3d67dd05..d305374bfda 100644 --- a/invokeai/app/services/events/events_base.py +++ b/invokeai/app/services/events/events_base.py @@ -53,7 +53,6 @@ if TYPE_CHECKING: from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput - from invokeai.app.services.board_records.board_records_common import BoardRecord from invokeai.app.services.download.download_base import DownloadJob from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.intermediates.intermediates_common import IntermediatesOperation @@ -395,38 +394,26 @@ def emit_intermediates_operation_changed(self, operation: "IntermediatesOperatio # region Media uploads - def emit_image_uploaded( - self, image_dto: "ImageDTO", user_id: str, board: Optional["BoardRecord"], shared_user_ids: list[str] - ) -> None: - """Emitted when a gallery-visible image arrives through the upload API; `board` is the one it was sent to""" + def emit_image_uploaded(self, image_dto: "ImageDTO", user_id: str) -> None: + """Emitted when a gallery-visible image arrives through the upload API""" # The board attach can fail quietly inside create(); the DTO says where the image actually is. - board = board if image_dto.board_id is not None else None self.dispatch( ImageUploadedEvent.build( image_name=image_dto.image_name, image_category=image_dto.image_category, user_id=user_id, board_id=image_dto.board_id, - board_owner_id=board.user_id if board else None, - board_visibility=board.board_visibility if board else None, - shared_user_ids=shared_user_ids if board else [], ) ) - def emit_video_uploaded( - self, video_dto: "VideoDTO", user_id: str, board: Optional["BoardRecord"], shared_user_ids: list[str] - ) -> None: - """Emitted when a gallery-visible video arrives through the upload API; `board` is the one it was sent to""" - board = board if video_dto.board_id is not None else None + def emit_video_uploaded(self, video_dto: "VideoDTO", user_id: str) -> None: + """Emitted when a gallery-visible video arrives through the upload API""" self.dispatch( VideoUploadedEvent.build( video_name=video_dto.video_name, video_category=video_dto.video_category, user_id=user_id, board_id=video_dto.board_id, - board_owner_id=board.user_id if board else None, - board_visibility=board.board_visibility if board else None, - shared_user_ids=shared_user_ids if board else [], ) ) diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index 1f5ff2712ea..8377cf36959 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -4,7 +4,6 @@ from fastapi_events.registry.payload_schema import registry as payload_schema from pydantic import BaseModel, ConfigDict, Field, model_validator -from invokeai.app.services.board_records.board_records_common import BoardVisibility from invokeai.app.services.image_records.image_records_common import ImageCategory from invokeai.app.services.intermediates.intermediates_common import IntermediatesOperation from invokeai.app.services.model_install.model_install_common import ModelInstallJob, ModelSource @@ -1093,16 +1092,6 @@ class MediaUploadedEventBase(EventBase): user_id: str = Field(description="The user who uploaded the media") board_id: Optional[str] = Field(default=None, description="The board the media was added to, if any") - board_owner_id: Optional[str] = Field( - default=None, description="The owner of that board, who may differ from the uploader; None for no board" - ) - board_visibility: Optional[BoardVisibility] = Field( - default=None, description="The visibility of that board; None when the media landed on no board" - ) - shared_user_ids: list[str] = Field( - default_factory=list, - description="Users a private board is explicitly shared with, who can see this media too", - ) @payload_schema.register @@ -1121,18 +1110,12 @@ def build( image_category: ImageCategory, user_id: str, board_id: Optional[str], - board_owner_id: Optional[str], - board_visibility: Optional[BoardVisibility], - shared_user_ids: list[str], ) -> "ImageUploadedEvent": return cls( image_name=image_name, image_category=image_category, user_id=user_id, board_id=board_id, - board_owner_id=board_owner_id, - board_visibility=board_visibility, - shared_user_ids=shared_user_ids, ) @@ -1152,18 +1135,12 @@ def build( video_category: ImageCategory, user_id: str, board_id: Optional[str], - board_owner_id: Optional[str], - board_visibility: Optional[BoardVisibility], - shared_user_ids: list[str], ) -> "VideoUploadedEvent": return cls( video_name=video_name, video_category=video_category, user_id=user_id, board_id=board_id, - board_owner_id=board_owner_id, - board_visibility=board_visibility, - shared_user_ids=shared_user_ids, ) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 41de3b8d704..f5416b02bb6 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from invokeai.app.services.board_access.board_access_default import BoardAccessService from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase @@ -116,6 +117,7 @@ def __init__( progress_previews: "ProgressPreviewsBase | None" = None, fonts: "FontService | None" = None, gallery_maintenance: "GalleryMaintenanceService | None" = None, + board_access: "BoardAccessService | None" = None, ): self.board_images = board_images self.board_image_records = board_image_records @@ -168,3 +170,6 @@ def __init__( self.fonts = fonts self.intermediates = intermediates self.gallery_maintenance = gallery_maintenance + # Stateless over the database, so a real one is the default: no caller should get looser rules by leaving it + # out. + self.board_access = board_access or BoardAccessService(database) diff --git a/invokeai/app/services/project_records/project_records_default.py b/invokeai/app/services/project_records/project_records_default.py index b95574864e3..efd25edb7e2 100644 --- a/invokeai/app/services/project_records/project_records_default.py +++ b/invokeai/app/services/project_records/project_records_default.py @@ -2,7 +2,8 @@ import uuid from typing import Any, Literal, Optional -from invokeai.app.services.board_records.board_records_common import BOARD_NAME_MAX_LENGTH, BoardVisibility +from invokeai.app.services.board_access.board_access_common import GeneralAccess +from invokeai.app.services.board_records.board_records_common import BOARD_NAME_MAX_LENGTH from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, @@ -80,7 +81,7 @@ def _claim_board(q: Queries, *, user_id: str, board_id: str, board_name: str, pr board = q.boards.lock(board_id) if board is None: raise ProjectBoardNotFoundError(board_id) - owner, visibility = board + owner, general_access = board if owner != user_id: raise ProjectBoardNotFoundError(board_id) claimant = q.projects.claimant(board_id) @@ -88,7 +89,7 @@ def _claim_board(q: Queries, *, user_id: str, board_id: str, board_name: str, pr assert record is not None if ( record.project_id not in (None, project_id) - or visibility != BoardVisibility.Private.value + or general_access != GeneralAccess.Restricted.value or claimant not in (None, (user_id, project_id)) or q.boards.is_shared(board_id) ): diff --git a/invokeai/app/services/session_processor/workflow_call_batch.py b/invokeai/app/services/session_processor/workflow_call_batch.py index d2416c058b3..806afe40cf7 100644 --- a/invokeai/app/services/session_processor/workflow_call_batch.py +++ b/invokeai/app/services/session_processor/workflow_call_batch.py @@ -10,7 +10,7 @@ from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator from invokeai.app.invocations.fields import ImageField -from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy, BoardVisibility +from invokeai.app.services.board_access.board_access_common import BoardAccessDeniedError, BoardAction from invokeai.app.services.image_records.image_records_common import ASSETS_CATEGORIES, IMAGE_CATEGORIES from invokeai.app.services.session_queue.session_queue_common import ( Batch, @@ -22,7 +22,6 @@ ) from invokeai.app.services.shared.execution_state_migration import load_execution_state from invokeai.app.services.shared.graph import GraphExecutionState, WorkflowCallFrame -from invokeai.app.services.shared.pagination import SQLiteDirection from invokeai.app.services.shared.workflow_graph_builder import ( UnsupportedWorkflowNodeError, apply_workflow_inputs_to_workflow, @@ -313,53 +312,25 @@ def _assert_user_can_access_board(board_id: str, services: Any, user_id: str | N if not user_id: return - board_records = getattr(services, "board_records", None) - if board_records is None or not hasattr(board_records, "get"): - return - users = getattr(services, "users", None) user = users.get(user_id) if users is not None and hasattr(users, "get") else None + # A deactivated account keeps no queue-time privileges, an admin's included. + if user is not None and getattr(user, "is_active", True) is False: + raise UnsupportedWorkflowNodeError( + f"call_saved_workflow caller does not have access to board '{board_id}' for image generator expansion" + ) is_admin = bool(user and getattr(user, "is_admin", False)) - if is_admin: - return - try: - board_record = board_records.get(board_id) + services.board_access.require(board_id, user_id, is_admin, BoardAction.Read) + except BoardAccessDeniedError as e: + raise UnsupportedWorkflowNodeError( + f"call_saved_workflow caller does not have access to board '{board_id}' for image generator expansion" + ) from e except Exception as e: raise UnsupportedWorkflowNodeError( f"call_saved_workflow could not access board '{board_id}' for image generator expansion" ) from e - if getattr(board_record, "user_id", None) == user_id: - return - - board_visibility = getattr(board_record, "board_visibility", BoardVisibility.Private) - if isinstance(board_visibility, str): - try: - board_visibility = BoardVisibility(board_visibility) - except ValueError: - board_visibility = BoardVisibility.Private - if board_visibility in {BoardVisibility.Shared, BoardVisibility.Public}: - return - - if hasattr(board_records, "get_all"): - try: - accessible_boards = board_records.get_all( - user_id=user_id, - is_admin=False, - order_by=BoardRecordOrderBy.Name, - direction=SQLiteDirection.Ascending, - include_archived=True, - ) - except Exception: - accessible_boards = [] - if any(getattr(board, "board_id", None) == board_id for board in accessible_boards): - return - - raise UnsupportedWorkflowNodeError( - f"call_saved_workflow caller does not have access to board '{board_id}' for image generator expansion" - ) - def _resolve_image_generator(value: Mapping[str, Any], services: Any, user_id: str | None) -> list[ImageField]: generator_type = value.get("type") diff --git a/invokeai/app/services/shared/database/queries/boards.py b/invokeai/app/services/shared/database/queries/boards.py index 9fb99caf9eb..16513986801 100644 --- a/invokeai/app/services/shared/database/queries/boards.py +++ b/invokeai/app/services/shared/database/queries/boards.py @@ -23,11 +23,11 @@ update, ) +from invokeai.app.services.board_access.board_access_common import GeneralAccess from invokeai.app.services.board_records.board_records_common import ( BoardChanges, BoardRecord, BoardRecordOrderBy, - BoardVisibility, ) from invokeai.app.services.shared.database.dialect import CaseInsensitiveOrder from invokeai.app.services.shared.database.queries.base import IN_CHUNK, QueryModule, locking, mapped, read, write @@ -36,19 +36,6 @@ from invokeai.app.services.shared.database.schema.projects import projects from invokeai.app.services.shared.pagination import SQLiteDirection -# Board visibility as the services still see it, read from and written to general access: private is restricted, -# shared is viewer, public is contributor. -_GENERAL_ACCESS_BY_VISIBILITY = { - BoardVisibility.Private: "restricted", - BoardVisibility.Shared: "viewer", - BoardVisibility.Public: "contributor", -} -_VISIBILITY = case( - (boards.c.general_access == "viewer", BoardVisibility.Shared.value), - (boards.c.general_access == "contributor", BoardVisibility.Public.value), - else_=BoardVisibility.Private.value, -).label("board_visibility") - _BOARD_COLUMNS = ( boards.c.board_id, boards.c.board_name, @@ -58,7 +45,7 @@ boards.c.deleted_at, boards.c.cover_image_name, boards.c.archived, - _VISIBILITY, + boards.c.general_access, boards.c.project_id, ) _CLAIMED = exists().where(projects.c.board_id == boards.c.board_id) @@ -70,16 +57,31 @@ .where(boards.c.board_id == bindparam("board_id")) ) _EXISTS = select(literal(1)).where(boards.c.board_id == bindparam("board_id")) -_LOCK = select(boards.c.user_id, _VISIBILITY).where(boards.c.board_id == bindparam("board_id")).with_for_update() +_LOCK = ( + select(boards.c.user_id, boards.c.general_access) + .where(boards.c.board_id == bindparam("board_id")) + .with_for_update() +) _PROJECT_IDS = select(projects.c.board_id, projects.c.project_id).where( projects.c.board_id.in_(bindparam("board_ids", expanding=True)) ) _INSERT = insert(boards) _DELETE_IF_UNCLAIMED = delete(boards).where(boards.c.board_id == bindparam("board_id"), ~_CLAIMED) -_IS_SHARED_WITH = select(literal(1)).where( - board_members.c.board_id == bindparam("board_id"), board_members.c.user_id == bindparam("user_id") -) _SHARED_USER_IDS = select(board_members.c.user_id).where(board_members.c.board_id == bindparam("board_id")) +# What a caller's access to a board rests on, in one query: its owner, general access, the caller's membership +# role, and whether it is a project's inbox. +_ACCESS_FACTS = select( + boards.c.user_id, + boards.c.general_access, + select(board_members.c.role) + .where(board_members.c.board_id == boards.c.board_id, board_members.c.user_id == bindparam("user_id")) + .scalar_subquery(), + _CLAIMED, +).where(boards.c.board_id == bindparam("board_id")) +_MEMBER_ROLES = select(board_members.c.board_id, board_members.c.role).where( + board_members.c.user_id == bindparam("user_id"), + board_members.c.board_id.in_(bindparam("board_ids", expanding=True)), +) _IS_SHARED = select(exists().where(board_members.c.board_id == bindparam("board_id"))) _RENAME = ( update(boards) @@ -116,13 +118,9 @@ def _board(row: Sequence[Any]) -> BoardRecord: deleted_at, cover_image_name, archived, - visibility, + general_access, project_id, ) = row - try: - board_visibility = BoardVisibility(visibility) - except ValueError: - board_visibility = BoardVisibility.Private return BoardRecord( board_id=board_id, board_name=board_name, @@ -132,7 +130,7 @@ def _board(row: Sequence[Any]) -> BoardRecord: deleted_at=deleted_at, cover_image_name=cover_image_name, archived=archived, - board_visibility=board_visibility, + general_access=GeneralAccess(general_access), project_id=project_id, ) @@ -157,7 +155,7 @@ def _boards_and_total(result: tuple[Sequence[Sequence[Any]], int]) -> tuple[list @functools.cache def _update(changes_owned_state: bool) -> Update: """Sets the given fields (a NULL leaves one as it is). A change to what a project owns -- the name, the - archived flag, the visibility -- applies only to a board no project claims.""" + archived flag, the general access -- applies only to a board no project claims.""" statement = ( update(boards) .where(boards.c.board_id == bindparam("target_board_id")) @@ -233,19 +231,19 @@ def exists(self, conn: Connection, board_id: str) -> bool: @locking def lock(self, conn: Connection, board_id: str) -> Optional[tuple[str, str]]: - """Locks the board's row until the transaction ends: its owner and visibility, or None when there is no + """Locks the board's row until the transaction ends: its owner and general access, or None when there is no such board. Locked before a transaction reads what it decides on, the row makes every transaction that changes the - board -- its owner, visibility or sharing, a project claiming it, its deletion -- wait until this one has + board -- its owner, access or sharing, a project claiming it, its deletion -- wait until this one has ended, and this one's later reads see what such a transaction committed before. Lock the board's project first, if there is one: rows are locked in that order. """ row: Optional[Sequence[Any]] = conn.execute(_LOCK, {"board_id": board_id}).first() if row is None: return None - user_id, board_visibility = row - return user_id, board_visibility + user_id, general_access = row + return user_id, general_access @read def project_ids(self, conn: Connection, board_ids: Sequence[str]) -> dict[str, str]: @@ -291,14 +289,33 @@ def all( statement = _list(is_admin, include_archived, order_by, direction, False) return conn.execute(statement, {"user_id": user_id}).all() - @read - def is_shared_with(self, conn: Connection, board_id: str, user_id: str) -> bool: - return conn.execute(_IS_SHARED_WITH, {"board_id": board_id, "user_id": user_id}).first() is not None - @read def shared_user_ids(self, conn: Connection, board_id: str) -> list[str]: return list(conn.execute(_SHARED_USER_IDS, {"board_id": board_id}).scalars().all()) + @read + def access_facts( + self, conn: Connection, board_id: str, user_id: str + ) -> Optional[tuple[str, str, Optional[str], bool]]: + """The board's owner and general access, the user's membership role on it, and whether it is a project's + inbox; None when there is no such board.""" + row: Optional[Sequence[Any]] = conn.execute(_ACCESS_FACTS, {"board_id": board_id, "user_id": user_id}).first() + if row is None: + return None + owner_id, general_access, member_role, is_inbox = row + return owner_id, general_access, member_role, bool(is_inbox) + + @read + def member_roles(self, conn: Connection, board_ids: Sequence[str], user_id: str) -> dict[str, str]: + """The user's membership role on each of these boards it is a member of.""" + roles: dict[str, str] = {} + for chunk in itertools.batched(board_ids, IN_CHUNK): + roles.update( + (row[0], row[1]) + for row in conn.execute(_MEMBER_ROLES, {"board_ids": list(chunk), "user_id": user_id}).all() + ) + return roles + @read def is_shared(self, conn: Connection, board_id: str) -> bool: """Whether the board is shared with any account.""" @@ -315,11 +332,11 @@ def insert( @write def update(self, conn: Connection, board_id: str, changes: BoardChanges) -> bool: """Applies the changes; whether a board took them. A board a project claims keeps its name, archived flag - and visibility, so a change to one of those leaves it as it is.""" + and general access, so a change to one of those leaves it as it is.""" changes_owned_state = ( changes.board_name is not None or changes.archived is not None - or changes.board_visibility is not None + or changes.general_access is not None or changes.moves_board ) result = conn.execute( @@ -331,11 +348,7 @@ def update(self, conn: Connection, board_id: str, changes: BoardChanges) -> bool "new_board_name": changes.board_name, "new_cover_image_name": changes.cover_image_name, "new_archived": changes.archived, - "new_general_access": ( - _GENERAL_ACCESS_BY_VISIBILITY[changes.board_visibility] - if changes.board_visibility is not None - else None - ), + "new_general_access": changes.general_access.value if changes.general_access is not None else None, }, ) return result.rowcount > 0 diff --git a/invokeai/app/services/shared/database/schema/boards.py b/invokeai/app/services/shared/database/schema/boards.py index 04d57fc44e7..7c57d45e6f1 100644 --- a/invokeai/app/services/shared/database/schema/boards.py +++ b/invokeai/app/services/shared/database/schema/boards.py @@ -3,7 +3,6 @@ from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index from invokeai.app.services.shared.database.schema.metadata import ( - ENUM_LENGTH, USER_ID_LENGTH, default, inserted_at, @@ -28,14 +27,13 @@ # clear it instead. Column("project_id", Key()), # The role every other account has on the board: 'restricted' (none), 'viewer' or 'contributor'. - Column("general_access", Key(ENUM_LENGTH), nullable=False, server_default=default("restricted")), + Column("general_access", LongText(), nullable=False, server_default=default("restricted")), CheckConstraint("general_access IN ('restricted', 'viewer', 'contributor')", name="general_access"), ) Index("idx_boards_created_at", boards.c.created_at) Index("idx_boards_user_id", boards.c.user_id) Index("idx_boards_project_id", boards.c.project_id) -Index("idx_boards_general_access", boards.c.general_access) # The accounts a board is shared with, and their role. The owner is never a member: `boards.user_id` is. board_members = table( diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py index ecfec580948..713cc84ce33 100644 --- a/invokeai/app/services/shared/invocation_context.py +++ b/invokeai/app/services/shared/invocation_context.py @@ -10,7 +10,14 @@ 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, BoardVisibility +from invokeai.app.services.board_access.board_access_common import ( + BoardAccessDeniedError, + BoardAction, + BoardViewer, + ItemAccessDeniedError, + ItemAction, +) +from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException, 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 @@ -115,6 +122,23 @@ def _build_execution_effects(data: InvocationContextData) -> ExecutionEffectsRec class BoardsInterface(InvocationContextInterface): + def _viewer(self, refusal: str) -> BoardViewer: + """The queue user, as the boards API sees a caller: an admin in single-user mode, their own role in + multiuser mode. A deactivated account keeps no queue-time privileges (see ImagesInterface._assert_read_access).""" + user_id = self._data.queue_item.user_id + if not self._services.configuration.multiuser: + return BoardViewer(user_id=user_id, is_admin=True) + user = self._services.users.get(user_id) + if user is not None and not user.is_active: + raise PermissionError(refusal) + return BoardViewer(user_id=user_id, is_admin=user is not None and user.is_admin) + + def _require(self, board_id: str, viewer: BoardViewer, action: BoardAction, refusal: str) -> None: + try: + self._services.board_access.require(board_id, viewer.user_id, viewer.is_admin, action) + except BoardAccessDeniedError: + raise PermissionError(refusal) + def create(self, board_name: str) -> BoardDTO: """Creates a board for the current user. @@ -124,8 +148,8 @@ def create(self, board_name: str) -> BoardDTO: Returns: The created board DTO. """ - user_id = self._data.queue_item.user_id - return self._services.boards.create(board_name, user_id) + viewer = self._viewer("Queue user is not authorized to create boards") + return self._services.boards.create(board_name, viewer.user_id, viewer=viewer) def get_dto(self, board_id: str) -> BoardDTO: """Gets a board DTO. @@ -136,7 +160,10 @@ def get_dto(self, board_id: str) -> BoardDTO: Returns: The board DTO. """ - return self._services.boards.get_dto(board_id) + refusal = "Queue user is not authorized to access this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.Read, refusal) + return self._services.boards.get_dto(board_id, viewer=viewer) def get_all(self) -> list[BoardDTO]: """Gets all boards accessible to the current user. @@ -144,19 +171,9 @@ def get_all(self) -> list[BoardDTO]: Returns: A list of all boards accessible to the current user. """ - user_id = self._data.queue_item.user_id - # Mirrors the boards API: single-user mode runs as an admin, multiuser mode uses the queue user's role. - if not self._services.configuration.multiuser: - is_admin = True - else: - user = self._services.users.get(user_id) - # See ImagesInterface._assert_read_access: deactivated accounts keep no - # queue-time privileges, including an admin's view of other users' boards. - if user is not None and not user.is_active: - raise PermissionError("Queue user is not authorized to list boards") - is_admin = user is not None and user.is_admin + viewer = self._viewer("Queue user is not authorized to list boards") return self._services.boards.get_all( - user_id, is_admin, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending + viewer.user_id, viewer.is_admin, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending ) def add_image_to_board(self, board_id: str, image_name: str) -> None: @@ -166,6 +183,14 @@ def add_image_to_board(self, board_id: str, image_name: str) -> None: board_id: The ID of the board to add the image to. image_name: The name of the image to add to the board. """ + refusal = "Queue user is not authorized to add this image to this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.AddMedia, refusal) + # Only an image's owner may put it on a board, as through the boards API. + try: + self._services.board_access.require_image(image_name, viewer.user_id, viewer.is_admin, ItemAction.Edit) + except ItemAccessDeniedError: + raise PermissionError(refusal) 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]: @@ -177,6 +202,9 @@ def get_all_image_names_for_board(self, board_id: str) -> list[str]: Returns: A list of all image names for the board. """ + refusal = "Queue user is not authorized to access this board" + viewer = self._viewer(refusal) + self._require(board_id, viewer, BoardAction.Read, refusal) return self._services.board_images.get_all_board_image_names_for_board( board_id, categories=None, @@ -233,14 +261,10 @@ def _assert_read_access(self, image_name: str) -> None: # processor's owner checks. if user is None or not user.is_active: raise PermissionError("Queue user is not authorized to access this image") - if user.is_admin or self._services.image_records.get_user_id(image_name) == user_id: - return - board_id = self._services.board_image_records.get_board_for_image(image_name) - if board_id is not None: - board = self._services.boards.get_dto(board_id) - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return - raise PermissionError("Queue user is not authorized to access this image") + try: + self._services.board_access.require_image(image_name, user_id, user.is_admin, ItemAction.Read) + except ItemAccessDeniedError: + raise PermissionError("Queue user is not authorized to access this image") def save( self, @@ -291,12 +315,12 @@ def save( if user is None or not user.is_active: raise PermissionError("Queue user is not authorized to save images") if board_id_ is not None: - board = self._services.boards.get_dto(board_id_) - if ( - not user.is_admin - and board.user_id != self._data.queue_item.user_id - and board.board_visibility != BoardVisibility.Public - ): + # Checked when the output is saved, against the role the user has now. + try: + self._services.board_access.require( + board_id_, self._data.queue_item.user_id, user.is_admin, BoardAction.AddMedia + ) + except (BoardAccessDeniedError, BoardRecordNotFoundException): raise PermissionError("Queue user is not authorized to save images to this board") workflow_ = self._get_workflow_json() @@ -405,14 +429,10 @@ def _assert_read_access(self, video_name: str) -> None: # queue-time privileges. if user is None or not user.is_active: raise PermissionError("Queue user is not authorized to access this video") - if user.is_admin or self._services.video_records.get_user_id(video_name) == user_id: - return - board_id = self._services.board_video_records.get_board_for_video(video_name) - if board_id is not None: - board = self._services.boards.get_dto(board_id) - if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public): - return - raise PermissionError("Queue user is not authorized to access this video") + try: + self._services.board_access.require_video(video_name, user_id, user.is_admin, ItemAction.Read) + except ItemAccessDeniedError: + raise PermissionError("Queue user is not authorized to access this video") def save( self, @@ -455,12 +475,12 @@ def save( if user is None or not user.is_active: raise PermissionError("Queue user is not authorized to save videos") if board_id_ is not None: - board = self._services.boards.get_dto(board_id_) - if ( - not user.is_admin - and board.user_id != self._data.queue_item.user_id - and board.board_visibility != BoardVisibility.Public - ): + # Checked when the output is saved, against the role the user has now. + try: + self._services.board_access.require( + board_id_, self._data.queue_item.user_id, user.is_admin, BoardAction.AddMedia + ) + except (BoardAccessDeniedError, BoardRecordNotFoundException): raise PermissionError("Queue user is not authorized to save videos to this board") workflow_ = self._get_workflow_json() diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py index d74773733c5..62608cd5962 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_10_10_board_roles.py @@ -24,7 +24,7 @@ table, ) -from invokeai.app.services.shared.database.schema.metadata import ENUM_LENGTH, USER_ID_LENGTH, default, inserted_at +from invokeai.app.services.shared.database.schema.metadata import USER_ID_LENGTH, default, inserted_at from invokeai.app.services.shared.database.types import Key, LongText from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import ( PortableMigration, @@ -84,7 +84,7 @@ def _board_roles(context: PortableMigrationContext) -> None: columns = {entry["name"] for entry in inspect(context.conn).get_columns("boards")} if "general_access" not in columns: context.op.add_column( - "boards", Column("general_access", Key(ENUM_LENGTH), nullable=False, server_default=default("restricted")) + "boards", Column("general_access", LongText(), nullable=False, server_default=default("restricted")) ) if "board_visibility" in columns: # Repeated by a run that resumes before the column is dropped, with the same result. @@ -116,8 +116,6 @@ def _board_roles(context: PortableMigrationContext) -> None: if not has_check: batch.create_check_constraint("general_access", _GENERAL_ACCESS_CHECK) - if "idx_boards_general_access" not in {index["name"] for index in inspect(context.conn).get_indexes("boards")}: - context.op.create_index("idx_boards_general_access", "boards", ["general_access"]) if "idx_board_members_user_id" not in { index["name"] for index in inspect(context.conn).get_indexes("board_members") }: diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index 6d44ea63835..8039fe4639b 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -10781,7 +10781,7 @@ "patch": { "tags": ["boards"], "summary": "Update Board", - "description": "Updates a board (user must have access to it).\n\nA project's inbox takes its name, archived state and visibility from the project and cannot be\nmoved, so those changes are refused for it \u2014 for admins too; its cover is still fair game. A\nboard in a project is private and unshared, so a move into a project, or a visibility change on\na member, is refused when the result would be otherwise. The storage decides all of this in one\ntransaction; the DTO read here is only for the ownership check.", + "description": "Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an\neditor; general access and moving between the Library and a project need the owner.\n\nA project's inbox takes its name, archived state and general access from the project and cannot be moved,\nso those changes are refused for it \u2014 for admins too; its cover is still fair game. The storage decides\nthat in one transaction.", "operationId": "update_board", "security": [ { @@ -10838,7 +10838,7 @@ "delete": { "tags": ["boards"], "summary": "Delete Board", - "description": "Deletes a board (user must have access to it)", + "description": "Deletes a board: its owner's, or an admin's, to do", "operationId": "delete_board", "security": [ { @@ -21102,6 +21102,22 @@ "$ref": "#/components/schemas/LatentsOutput" } }, + "BoardAction": { + "type": "string", + "enum": [ + "read", + "add_media", + "manage_items", + "rename", + "set_cover", + "archive", + "set_general_access", + "move", + "delete" + ], + "title": "BoardAction", + "description": "What a caller can do to a board as a whole." + }, "BoardChanges": { "properties": { "board_name": { @@ -21142,6 +21158,17 @@ "title": "Archived", "description": "Whether or not the board is archived" }, + "general_access": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeneralAccess" + }, + { + "type": "null" + } + ], + "description": "The role every other account gets on the board. Only its owner may change it." + }, "board_visibility": { "anyOf": [ { @@ -21151,7 +21178,8 @@ "type": "null" } ], - "description": "The visibility of the board." + "description": "Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`.", + "deprecated": true }, "project_id": { "anyOf": [ @@ -21246,10 +21274,10 @@ "title": "Archived", "description": "Whether or not the board is archived." }, - "board_visibility": { - "$ref": "#/components/schemas/BoardVisibility", - "description": "The visibility of the board.", - "default": "private" + "general_access": { + "$ref": "#/components/schemas/GeneralAccess", + "description": "The role every other account gets on the board: none (restricted), viewer or contributor.", + "default": "restricted" }, "project_id": { "anyOf": [ @@ -21307,7 +21335,38 @@ } ], "title": "Owner Username", - "description": "The username of the board owner (for admin view)." + "description": "The board owner's display name. Admins get the email of an owner without one; other callers get 'Unknown user'. Null when the owner's account no longer exists." + }, + "board_visibility": { + "$ref": "#/components/schemas/BoardVisibility", + "description": "Deprecated, to be removed in 8.0: `general_access` under its old name.", + "default": "private", + "deprecated": true + }, + "my_role": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardRole" + }, + { + "type": "null" + } + ], + "description": "The caller's own role on the board; null when only admin rights give access." + }, + "allowed_actions": { + "items": { + "$ref": "#/components/schemas/BoardAction" + }, + "type": "array", + "title": "Allowed Actions", + "description": "The board actions the caller may take. Gate interfaces on these." + }, + "admin_override": { + "type": "boolean", + "title": "Admin Override", + "description": "Whether admin rights give the caller more than `my_role` does.", + "default": false }, "is_inbox": { "type": "boolean", @@ -21350,11 +21409,17 @@ "title": "BoardRecordOrderBy", "description": "The order by options for board records" }, + "BoardRole": { + "type": "string", + "enum": ["viewer", "contributor", "editor", "owner"], + "title": "BoardRole", + "description": "A caller's role on a board. Each role allows everything the roles before it allow." + }, "BoardVisibility": { "type": "string", "enum": ["private", "shared", "public"], "title": "BoardVisibility", - "description": "The visibility options for a board." + "description": "Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients." }, "Body_acknowledge_enqueue_batch": { "properties": { @@ -42039,6 +42104,12 @@ "title": "Gemma4Encoder_Gemma4Encoder_LTX2_Config", "description": "The LTX-2.5 Gemma-4-12B text encoder as a folder (config, tokenizer, one weight file)." }, + "GeneralAccess": { + "type": "string", + "enum": ["restricted", "viewer", "contributor"], + "title": "GeneralAccess", + "description": "The role every other account gets on a board: none, viewer or contributor." + }, "GeneratePasswordResponse": { "properties": { "password": { @@ -50317,39 +50388,6 @@ "description": "The board the media was added to, if any", "title": "Board Id" }, - "board_owner_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The owner of that board, who may differ from the uploader; None for no board", - "title": "Board Owner Id" - }, - "board_visibility": { - "anyOf": [ - { - "$ref": "#/components/schemas/BoardVisibility" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The visibility of that board; None when the media landed on no board" - }, - "shared_user_ids": { - "description": "Users a private board is explicitly shared with, who can see this media too", - "items": { - "type": "string" - }, - "title": "Shared User Ids", - "type": "array" - }, "image_name": { "description": "The name of the uploaded image", "title": "Image Name", @@ -50360,16 +50398,7 @@ "description": "The category of the uploaded image" } }, - "required": [ - "timestamp", - "user_id", - "board_id", - "board_owner_id", - "board_visibility", - "shared_user_ids", - "image_name", - "image_category" - ], + "required": ["timestamp", "user_id", "board_id", "image_name", "image_category"], "title": "ImageUploadedEvent", "type": "object" }, @@ -109786,39 +109815,6 @@ "description": "The board the media was added to, if any", "title": "Board Id" }, - "board_owner_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The owner of that board, who may differ from the uploader; None for no board", - "title": "Board Owner Id" - }, - "board_visibility": { - "anyOf": [ - { - "$ref": "#/components/schemas/BoardVisibility" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The visibility of that board; None when the media landed on no board" - }, - "shared_user_ids": { - "description": "Users a private board is explicitly shared with, who can see this media too", - "items": { - "type": "string" - }, - "title": "Shared User Ids", - "type": "array" - }, "video_name": { "description": "The name of the uploaded video", "title": "Video Name", @@ -109829,16 +109825,7 @@ "description": "The category of the uploaded video" } }, - "required": [ - "timestamp", - "user_id", - "board_id", - "board_owner_id", - "board_visibility", - "shared_user_ids", - "video_name", - "video_category" - ], + "required": ["timestamp", "user_id", "board_id", "video_name", "video_category"], "title": "VideoUploadedEvent", "type": "object" }, diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index 4fb43ac69f8..05a045ed960 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -2463,20 +2463,19 @@ export type paths = { post?: never; /** * Delete Board - * @description Deletes a board (user must have access to it) + * @description Deletes a board: its owner's, or an admin's, to do */ delete: operations["delete_board"]; options?: never; head?: never; /** * Update Board - * @description Updates a board (user must have access to it). + * @description Updates a board. Each field takes its own board action: renaming, setting the cover and archiving need an + * editor; general access and moving between the Library and a project need the owner. * - * A project's inbox takes its name, archived state and visibility from the project and cannot be - * moved, so those changes are refused for it — for admins too; its cover is still fair game. A - * board in a project is private and unshared, so a move into a project, or a visibility change on - * a member, is refused when the result would be otherwise. The storage decides all of this in one - * transaction; the DTO read here is only for the ownership check. + * A project's inbox takes its name, archived state and general access from the project and cannot be moved, + * so those changes are refused for it — for admins too; its cover is still fair game. The storage decides + * that in one transaction. */ patch: operations["update_board"]; trace?: never; @@ -6175,6 +6174,12 @@ export type components = { */ type: "lblend"; }; + /** + * BoardAction + * @description What a caller can do to a board as a whole. + * @enum {string} + */ + BoardAction: "read" | "add_media" | "manage_items" | "rename" | "set_cover" | "archive" | "set_general_access" | "move" | "delete"; /** BoardChanges */ BoardChanges: { /** @@ -6192,7 +6197,12 @@ export type components = { * @description Whether or not the board is archived */ archived?: boolean | null; - /** @description The visibility of the board. */ + /** @description The role every other account gets on the board. Only its owner may change it. */ + general_access?: components["schemas"]["GeneralAccess"] | null; + /** + * @deprecated + * @description Deprecated, to be removed in 8.0: general access under its old name. Send `general_access`. + */ board_visibility?: components["schemas"]["BoardVisibility"] | null; /** * Project Id @@ -6246,10 +6256,10 @@ export type components = { */ archived: boolean; /** - * @description The visibility of the board. - * @default private + * @description The role every other account gets on the board: none (restricted), viewer or contributor. + * @default restricted */ - board_visibility?: components["schemas"]["BoardVisibility"]; + general_access?: components["schemas"]["GeneralAccess"]; /** * Project Id * @description The id of the owner's project this board belongs to; absent for a Library board. @@ -6284,9 +6294,28 @@ export type components = { asset_video_count?: number; /** * Owner Username - * @description The username of the board owner (for admin view). + * @description The board owner's display name. Admins get the email of an owner without one; other callers get 'Unknown user'. Null when the owner's account no longer exists. */ owner_username?: string | null; + /** + * @deprecated + * @description Deprecated, to be removed in 8.0: `general_access` under its old name. + * @default private + */ + board_visibility?: components["schemas"]["BoardVisibility"]; + /** @description The caller's own role on the board; null when only admin rights give access. */ + my_role?: components["schemas"]["BoardRole"] | null; + /** + * Allowed Actions + * @description The board actions the caller may take. Gate interfaces on these. + */ + allowed_actions?: components["schemas"]["BoardAction"][]; + /** + * Admin Override + * @description Whether admin rights give the caller more than `my_role` does. + * @default false + */ + admin_override?: boolean; /** * Is Inbox * @description Whether this board is its project's inbox, which only the project APIs may change. @@ -6311,9 +6340,15 @@ export type components = { * @enum {string} */ BoardRecordOrderBy: "created_at" | "board_name"; + /** + * BoardRole + * @description A caller's role on a board. Each role allows everything the roles before it allow. + * @enum {string} + */ + BoardRole: "viewer" | "contributor" | "editor" | "owner"; /** * BoardVisibility - * @description The visibility options for a board. + * @description Deprecated: a board's general access under its pre-7.0 name. Kept until 8.0 for older clients. * @enum {string} */ BoardVisibility: "private" | "shared" | "public"; @@ -16945,6 +16980,12 @@ export type components = { */ cpu_only: boolean | null; }; + /** + * GeneralAccess + * @description The role every other account gets on a board: none, viewer or contributor. + * @enum {string} + */ + GeneralAccess: "restricted" | "viewer" | "contributor"; /** * GeneratePasswordResponse * @description Response containing a generated password. @@ -20630,22 +20671,6 @@ export type components = { * @default null */ board_id: string | null; - /** - * Board Owner Id - * @description The owner of that board, who may differ from the uploader; None for no board - * @default null - */ - board_owner_id: string | null; - /** - * @description The visibility of that board; None when the media landed on no board - * @default null - */ - board_visibility: components["schemas"]["BoardVisibility"] | null; - /** - * Shared User Ids - * @description Users a private board is explicitly shared with, who can see this media too - */ - shared_user_ids: string[]; /** * Image Name * @description The name of the uploaded image @@ -49094,22 +49119,6 @@ export type components = { * @default null */ board_id: string | null; - /** - * Board Owner Id - * @description The owner of that board, who may differ from the uploader; None for no board - * @default null - */ - board_owner_id: string | null; - /** - * @description The visibility of that board; None when the media landed on no board - * @default null - */ - board_visibility: components["schemas"]["BoardVisibility"] | null; - /** - * Shared User Ids - * @description Users a private board is explicitly shared with, who can see this media too - */ - shared_user_ids: string[]; /** * Video Name * @description The name of the uploaded video diff --git a/tests/app/api/test_video_upload_limits.py b/tests/app/api/test_video_upload_limits.py index 17d5b0d90be..3d654b7ce48 100644 --- a/tests/app/api/test_video_upload_limits.py +++ b/tests/app/api/test_video_upload_limits.py @@ -35,7 +35,7 @@ _identify_video_upload_user_async, ) from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_access.board_access_common import BoardAccess, BoardRole from invokeai.app.services.image_records.image_records_common import ImageCategory from invokeai.app.util.video_ingest import VideoIngestError from invokeai.app.util.video_thumbnails import VideoDecodeTimeoutError @@ -842,9 +842,10 @@ def _reject_at_ingest() -> Iterator[None]: def _forbidden_board_deps() -> MagicMock: + """A board the caller can open but not add to.""" deps = MagicMock() - deps.invoker.services.board_records.get.return_value = SimpleNamespace( - user_id="someone-else", board_visibility=BoardVisibility.Private + deps.invoker.services.board_access.get.return_value = BoardAccess( + role=BoardRole.Viewer, is_admin=False, is_inbox=False ) return deps diff --git a/tests/app/routers/test_board_images_maintenance.py b/tests/app/routers/test_board_images_maintenance.py index c7e20fccdbd..7238009f024 100644 --- a/tests/app/routers/test_board_images_maintenance.py +++ b/tests/app/routers/test_board_images_maintenance.py @@ -7,8 +7,6 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app from invokeai.app.services.auth.token_service import TokenData -from invokeai.app.services.board_records.board_records_common import BoardRecord, BoardVisibility -from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker @@ -19,25 +17,6 @@ def __init__(self, invoker) -> None: self.invoker = invoker -def _board_record() -> BoardRecord: - """The record behind the DTO below. - - Board write access is decided off the record, not the DTO: ownership and visibility are - two columns, while boards.get_dto() also resolves a cover image and runs three COUNT - aggregates, and that cost per name is what the batch routes cannot afford. - """ - return BoardRecord( - board_id="board-id", - board_name="Board", - user_id="system", - created_at="2024-01-01 00:00:00.000", - updated_at="2024-01-01 00:00:00.000", - archived=False, - board_visibility=BoardVisibility.Private, - cover_image_name=None, - ) - - @pytest.fixture def client() -> TestClient: return TestClient(app) @@ -65,25 +44,7 @@ def test_board_image_mutations_are_blocked_during_image_move_maintenance( mock_invoker.services.image_moves.is_maintenance_active.return_value = True monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system")) monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=MagicMock(board_id=None))) - monkeypatch.setattr( - mock_invoker.services.boards, - "get_dto", - MagicMock( - return_value=BoardDTO( - board_id="board-id", - board_name="Board", - user_id="system", - created_at="2024-01-01 00:00:00.000", - updated_at="2024-01-01 00:00:00.000", - archived=False, - board_visibility=BoardVisibility.Private, - cover_image_name=None, - image_count=0, - asset_count=0, - ) - ), - ) - monkeypatch.setattr(mock_invoker.services.board_records, "get", MagicMock(return_value=_board_record())) + mock_invoker.services.database.queries.boards.insert(board_id="board-id", board_name="Board", user_id="system") monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) @@ -104,25 +65,7 @@ def test_board_image_mutation_checks_access_before_image_move_maintenance( mock_invoker.services.image_moves = MagicMock() mock_invoker.services.image_moves.is_maintenance_active.return_value = True monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user")) - monkeypatch.setattr( - mock_invoker.services.boards, - "get_dto", - MagicMock( - return_value=BoardDTO( - board_id="board-id", - board_name="Board", - user_id="system", - created_at="2024-01-01 00:00:00.000", - updated_at="2024-01-01 00:00:00.000", - archived=False, - board_visibility=BoardVisibility.Private, - cover_image_name=None, - image_count=0, - asset_count=0, - ) - ), - ) - monkeypatch.setattr(mock_invoker.services.board_records, "get", MagicMock(return_value=_board_record())) + board_id = mock_invoker.services.board_records.save("Board", "system").board_id monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) @@ -133,9 +76,10 @@ async def current_user_override() -> TokenData: app.dependency_overrides[get_current_user_or_default] = current_user_override try: - response = client.post("/api/v1/board_images/", json={"board_id": "board-id", "image_name": "image.png"}) + response = client.post("/api/v1/board_images/", json={"board_id": board_id, "image_name": "image.png"}) - assert response.status_code == 403 + # Another account's restricted board: unreadable, so it answers as missing. + assert response.status_code == 404 mock_invoker.services.image_moves.is_maintenance_active.assert_not_called() finally: app.dependency_overrides.pop(get_current_user_or_default, None) @@ -150,25 +94,13 @@ def test_delete_board_with_images_is_blocked_during_image_move_maintenance( mock_invoker.services.image_moves = MagicMock() mock_invoker.services.image_moves.is_maintenance_active.return_value = True mock_invoker.services.images.delete_images_on_board = MagicMock() - mock_invoker.services.boards.get_dto = MagicMock( - return_value=BoardDTO( - board_id="board-id", - board_name="Board", - user_id="system", - created_at="2024-01-01 00:00:00.000", - updated_at="2024-01-01 00:00:00.000", - archived=False, - board_visibility=BoardVisibility.Private, - cover_image_name=None, - image_count=0, - asset_count=0, - ) - ) + board_id = mock_invoker.services.board_records.save("Board", "system").board_id monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) - response = client.delete("/api/v1/boards/board-id?include_images=true") + response = client.delete(f"/api/v1/boards/{board_id}?include_images=true") assert response.status_code == 409 assert response.json()["detail"] == "Image storage maintenance is active" diff --git a/tests/app/routers/test_boards_multiuser.py b/tests/app/routers/test_boards_multiuser.py index 47f4e4fb10f..0499173007e 100644 --- a/tests/app/routers/test_boards_multiuser.py +++ b/tests/app/routers/test_boards_multiuser.py @@ -12,6 +12,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.boards import delete_board from invokeai.app.api_app import app +from invokeai.app.services.board_access.board_access_common import BoardViewer from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.database.database import Database from invokeai.app.services.users.users_common import UserCreateRequest @@ -274,7 +275,7 @@ def test_get_board_other_user_forbidden(client: TestClient, user1_token: str, us f"/api/v1/boards/{board_id}", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_board_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str): @@ -330,7 +331,7 @@ def test_update_board_other_user_forbidden(client: TestClient, user1_token: str, json={"board_name": "Hijacked Name"}, headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_update_board_admin_can_update_any_board(client: TestClient, admin_token: str, user1_token: str): @@ -386,7 +387,7 @@ def test_delete_board_other_user_forbidden(client: TestClient, user1_token: str, f"/api/v1/boards/{board_id}", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_delete_board_admin_can_delete_any_board(client: TestClient, admin_token: str, user1_token: str): @@ -440,7 +441,7 @@ def test_list_board_image_names_other_user_forbidden(client: TestClient, user1_t f"/api/v1/boards/{board_id}/image_names", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_list_board_image_names_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str): @@ -655,7 +656,7 @@ def test_private_board_not_visible_after_privacy_change(client: TestClient, user f"/api/v1/boards/{board_id}", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_token: str, user2_token: str): @@ -674,7 +675,7 @@ def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_toke json={"board_visibility": "public"}, headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_shared_board_image_names_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str): @@ -1017,7 +1018,7 @@ def test_board_update_cannot_race_a_project_claim( ): """The claimed-board check and mutation must be one database decision. - Returning an unclaimed DTO and claiming the board immediately afterwards pins the race without + Deciding access on an unclaimed board and claiming it immediately afterwards pins the race without sleeps or threads: an unconditional update sees stale state, while a conditional write cannot. """ created = client.post( @@ -1026,11 +1027,12 @@ def test_board_update_cannot_race_a_project_claim( ).json() board_id = created["board_id"] original_get_dto = mock_invoker.services.boards.get_dto + original_access = mock_invoker.services.board_access.get did_claim = False - def get_then_claim(*, board_id: str): + def get_then_claim(board_id: str, user_id: str, is_admin: bool): nonlocal did_claim - board = original_get_dto(board_id) + access = original_access(board_id, user_id, is_admin) if not did_claim: did_claim = True mock_invoker.services.project_records.create( @@ -1039,9 +1041,9 @@ def get_then_claim(*, board_id: str): data={}, board_id=board_id, ) - return board + return access - monkeypatch.setattr(mock_invoker.services.boards, "get_dto", get_then_claim) + monkeypatch.setattr(mock_invoker.services.board_access, "get", get_then_claim) response = client.patch( f"/api/v1/boards/{board_id}", @@ -1050,7 +1052,7 @@ def get_then_claim(*, board_id: str): ) assert response.status_code == status.HTTP_409_CONFLICT - board = original_get_dto(board_id) + board = original_get_dto(board_id, viewer=BoardViewer(user_id=created["user_id"], is_admin=False)) for field, value in unchanged.items(): assert getattr(board, field) == value @@ -1113,3 +1115,162 @@ def test_generic_deletion_of_a_project_board_is_refused_without_touching_media( client.get(f"/api/v1/boards/{board_id}", headers={"Authorization": f"Bearer {user1_token}"}).status_code == status.HTTP_200_OK ) + + +# --- Roles in the board DTO -------------------------------------------------------------------- + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _create(client: TestClient, token: str, name: str, project_id: str | None = None) -> dict[str, Any]: + query = f"board_name={name}" + (f"&project_id={project_id}" if project_id else "") + response = client.post(f"/api/v1/boards/?{query}", headers=_auth(token)) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + +def test_the_board_dto_describes_the_callers_access( + client: TestClient, user1_token: str, user2_token: str, admin_token: str +): + """Clients gate their interface on allowed_actions; my_role and admin_override are for display.""" + board = _create(client, user1_token, "Contributions") + client.patch( + f"/api/v1/boards/{board['board_id']}", json={"general_access": "contributor"}, headers=_auth(user1_token) + ) + + def described(token: str) -> tuple[Any, ...]: + dto = client.get(f"/api/v1/boards/{board['board_id']}", headers=_auth(token)).json() + return dto["my_role"], set(dto["allowed_actions"]), dto["admin_override"], dto["owner_username"] + + assert described(user1_token) == ("owner", _ALL_ACTIONS, False, "User One") + assert described(user2_token) == ("contributor", {"read", "add_media"}, False, "User One") + assert described(admin_token) == ("contributor", _ALL_ACTIONS, True, "User One") + + +_ALL_ACTIONS = { + "read", + "add_media", + "manage_items", + "rename", + "set_cover", + "archive", + "set_general_access", + "move", + "delete", +} + + +def test_only_the_owner_and_admins_learn_a_shared_boards_project( + client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str, admin_token: str +): + user1 = client.get("/api/v1/auth/me", headers=_auth(user1_token)).json()["user_id"] + project = mock_invoker.services.project_records.create(user1, "Atlas", {}).model_dump() + board = _create(client, user1_token, "Panels", project["project_id"]) + client.patch(f"/api/v1/boards/{board['board_id']}", json={"general_access": "viewer"}, headers=_auth(user1_token)) + + def project_of(token: str) -> Any: + return client.get(f"/api/v1/boards/{board['board_id']}", headers=_auth(token)).json().get("project_id") + + assert (project_of(user1_token), project_of(admin_token), project_of(user2_token)) == ( + project["project_id"], + project["project_id"], + None, + ) + listed = client.get("/api/v1/boards/?all=true", headers=_auth(user2_token)).json() + assert [dto.get("project_id") for dto in listed if dto["board_id"] == board["board_id"]] == [None] + + +@pytest.mark.parametrize( + ("visibility", "general_access"), [("private", "restricted"), ("shared", "viewer"), ("public", "contributor")] +) +def test_the_deprecated_board_visibility_is_general_access_under_its_old_name( + client: TestClient, user1_token: str, visibility: str, general_access: str +): + board = _create(client, user1_token, "Legacy") + + updated = client.patch( + f"/api/v1/boards/{board['board_id']}", json={"board_visibility": visibility}, headers=_auth(user1_token) + ).json() + + assert (updated["general_access"], updated["board_visibility"]) == (general_access, visibility) + + +def test_a_change_may_name_general_access_once(client: TestClient, user1_token: str): + board = _create(client, user1_token, "Both") + + response = client.patch( + f"/api/v1/boards/{board['board_id']}", + json={"board_visibility": "public", "general_access": "viewer"}, + headers=_auth(user1_token), + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_editors_manage_a_board_but_only_its_owner_changes_who_can_open_it( + client: TestClient, mock_sqlite_database: Database, user1_token: str, user2_token: str +): + board = _create(client, user1_token, "Managed") + user2 = client.get("/api/v1/auth/me", headers=_auth(user2_token)).json()["user_id"] + with sqlite_cursor(mock_sqlite_database) as cursor: + cursor.execute( + "INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'editor')", (board["board_id"], user2) + ) + + def patch_as_editor(changes: dict[str, Any]) -> int: + return client.patch(f"/api/v1/boards/{board['board_id']}", json=changes, headers=_auth(user2_token)).status_code + + assert patch_as_editor({"board_name": "Renamed by editor", "archived": True}) == status.HTTP_201_CREATED + with sqlite_cursor(mock_sqlite_database) as cursor: + cursor.execute( + "INSERT INTO images (image_name, image_origin, image_category, width, height, user_id) " + "VALUES ('cover.png', 'internal', 'general', 1, 1, ?)", + (user2,), + ) + assert patch_as_editor({"cover_image_name": "cover.png"}) == status.HTTP_201_CREATED + assert patch_as_editor({"general_access": "viewer"}) == status.HTTP_403_FORBIDDEN + # Moving between the Library and a project is the owner's, in either direction. + assert patch_as_editor({"project_id": None}) == status.HTTP_403_FORBIDDEN + assert ( + client.delete(f"/api/v1/boards/{board['board_id']}", headers=_auth(user2_token)).status_code + == status.HTTP_403_FORBIDDEN + ) + + +def test_an_update_that_changes_nothing_still_takes_an_editor( + client: TestClient, mock_sqlite_database: Database, user1_token: str, user2_token: str +): + board = _create(client, user1_token, "Untouched") + user2 = client.get("/api/v1/auth/me", headers=_auth(user2_token)).json()["user_id"] + with sqlite_cursor(mock_sqlite_database) as cursor: + cursor.execute( + "INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'viewer')", (board["board_id"], user2) + ) + + response = client.patch(f"/api/v1/boards/{board['board_id']}", json={}, headers=_auth(user2_token)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_a_board_shared_with_someone_else_stays_closed_to_everyone_else( + client: TestClient, mock_invoker: Invoker, mock_sqlite_database: Database, user1_token: str, user2_token: str +): + board = _create(client, user1_token, "Shared with one") + setup_test_user(mock_invoker, "user3@test.com", "User Three", is_admin=False) + user3_token = get_user_token(client, "user3@test.com") + user2 = client.get("/api/v1/auth/me", headers=_auth(user2_token)).json()["user_id"] + with sqlite_cursor(mock_sqlite_database) as cursor: + cursor.execute( + "INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, 'editor')", (board["board_id"], user2) + ) + + assert client.get(f"/api/v1/boards/{board['board_id']}", headers=_auth(user2_token)).status_code == 200 + assert client.get(f"/api/v1/boards/{board['board_id']}", headers=_auth(user3_token)).status_code == 404 + assert ( + client.patch( + f"/api/v1/boards/{board['board_id']}", json={"board_name": "Taken"}, headers=_auth(user3_token) + ).status_code + == 404 + ) diff --git a/tests/app/routers/test_boards_projects.py b/tests/app/routers/test_boards_projects.py index 86b0b8adbc8..d2bc50ea046 100644 --- a/tests/app/routers/test_boards_projects.py +++ b/tests/app/routers/test_boards_projects.py @@ -128,7 +128,10 @@ def test_moving_into_someone_elses_project_is_a_404_and_moves_nothing( assert _get_board(client, user1_token, board_id).get("project_id") is None -@pytest.mark.parametrize("changes", [{"board_name": "Renamed"}, {"archived": True}, {"board_visibility": "public"}]) +@pytest.mark.parametrize( + "changes", + [{"board_name": "Renamed"}, {"archived": True}, {"board_visibility": "public"}, {"general_access": "viewer"}], +) def test_an_inbox_cannot_be_changed_or_moved_through_the_board_routes( client: TestClient, user1_token: str, admin_token: str, changes: dict[str, Any] ): @@ -141,30 +144,35 @@ def test_an_inbox_cannot_be_changed_or_moved_through_the_board_routes( assert response.status_code == status.HTTP_409_CONFLICT, body inbox = _get_board(client, user1_token, project["board_id"]) + # Clients are told up front which changes the project owns. + assert set(inbox["allowed_actions"]) == {"read", "add_media", "manage_items", "set_cover"} assert inbox["board_name"] == "Owning project" assert inbox["archived"] is False assert inbox["board_visibility"] == "private" assert inbox["project_id"] == project["project_id"] -@pytest.mark.parametrize("visibility", ["shared", "public"]) -def test_a_non_private_board_cannot_enter_a_project(client: TestClient, user1_token: str, visibility: str): +@pytest.mark.parametrize("general_access", ["viewer", "contributor"]) +def test_a_board_keeps_its_general_access_when_it_enters_a_project( + client: TestClient, user1_token: str, general_access: str +): project = _create_project(client, user1_token) board_id = _create_board(client, user1_token, "Visible") assert ( client.patch( - f"/api/v1/boards/{board_id}", json={"board_visibility": visibility}, headers=_auth(user1_token) + f"/api/v1/boards/{board_id}", json={"general_access": general_access}, headers=_auth(user1_token) ).status_code == status.HTTP_201_CREATED ) - refused = _move(client, user1_token, board_id, project["project_id"]) + moved = _move(client, user1_token, board_id, project["project_id"]) - assert refused.status_code == status.HTTP_409_CONFLICT - assert _get_board(client, user1_token, board_id).get("project_id") is None + assert moved.status_code == status.HTTP_201_CREATED + board = _get_board(client, user1_token, board_id) + assert (board["project_id"], board["general_access"]) == (project["project_id"], general_access) -def test_an_explicitly_shared_board_cannot_enter_a_project( +def test_a_board_keeps_its_members_when_it_enters_a_project( client: TestClient, mock_sqlite_database: Database, user1_token: str, user2_token: str ): project = _create_project(client, user1_token) @@ -173,36 +181,41 @@ def test_an_explicitly_shared_board_cannot_enter_a_project( with mock_sqlite_database.begin(write=True) as conn: conn.execute(insert(board_members).values(board_id=board_id, user_id=recipient, role="viewer")) - assert _move(client, user1_token, board_id, project["project_id"]).status_code == status.HTTP_409_CONFLICT + assert _move(client, user1_token, board_id, project["project_id"]).status_code == status.HTTP_201_CREATED + # The member still reads it; the project it is in stays the owner's to know. + shared = _get_board(client, user2_token, board_id) + assert (shared["my_role"], shared.get("project_id")) == ("viewer", None) + + assert _move(client, user1_token, board_id, None).status_code == status.HTTP_201_CREATED + assert _get_board(client, user2_token, board_id)["my_role"] == "viewer" -@pytest.mark.parametrize("visibility", ["shared", "public"]) -def test_a_member_cannot_be_published(client: TestClient, user1_token: str, visibility: str): +def test_a_board_released_by_a_deleted_project_keeps_its_members( + client: TestClient, mock_sqlite_database: Database, user1_token: str, user2_token: str +): project = _create_project(client, user1_token) member = _create_member(client, user1_token, project["project_id"]) + recipient = client.get("/api/v1/auth/me", headers=_auth(user2_token)).json()["user_id"] + with mock_sqlite_database.begin(write=True) as conn: + conn.execute(insert(board_members).values(board_id=member["board_id"], user_id=recipient, role="editor")) - refused = client.patch( - f"/api/v1/boards/{member['board_id']}", json={"board_visibility": visibility}, headers=_auth(user1_token) - ) + deleted = client.delete(f"/api/v1/projects/{project['project_id']}?boards=release", headers=_auth(user1_token)) - assert refused.status_code == status.HTTP_409_CONFLICT - assert _get_board(client, user1_token, member["board_id"])["board_visibility"] == "private" + assert deleted.status_code == status.HTTP_204_NO_CONTENT + assert _get_board(client, user2_token, member["board_id"])["my_role"] == "editor" -def test_publishing_and_leaving_a_project_in_one_request_is_allowed(client: TestClient, user1_token: str): - """The rules apply to the board as it will be, not as it is.""" +@pytest.mark.parametrize("general_access", ["viewer", "contributor"]) +def test_a_member_can_be_shared(client: TestClient, user1_token: str, general_access: str): project = _create_project(client, user1_token) member = _create_member(client, user1_token, project["project_id"]) - response = client.patch( - f"/api/v1/boards/{member['board_id']}", - json={"board_visibility": "public", "project_id": None}, - headers=_auth(user1_token), + shared = client.patch( + f"/api/v1/boards/{member['board_id']}", json={"general_access": general_access}, headers=_auth(user1_token) ) - assert response.status_code == status.HTTP_201_CREATED - assert response.json()["board_visibility"] == "public" - assert response.json().get("project_id") is None + assert shared.status_code == status.HTTP_201_CREATED + assert _get_board(client, user1_token, member["board_id"])["general_access"] == general_access # --- members are ordinary boards ------------------------------------------------------------- diff --git a/tests/app/routers/test_gallery_item_location.py b/tests/app/routers/test_gallery_item_location.py index f8cd3383ce3..7bd9755e6dd 100644 --- a/tests/app/routers/test_gallery_item_location.py +++ b/tests/app/routers/test_gallery_item_location.py @@ -171,13 +171,11 @@ def test_missing_filtered_and_cross_user_targets_share_same_not_found( assert responses == [(status.HTTP_404_NOT_FOUND, "Gallery item not found")] * 3 -def test_inaccessible_explicit_board_keeps_existing_forbidden_behavior( - enable_multiuser: Any, mock_invoker: Invoker -) -> None: +def test_an_unreadable_explicit_board_answers_as_missing(enable_multiuser: Any, mock_invoker: Invoker) -> None: board = mock_invoker.services.board_records.save("Private", "bob") with pytest.raises(HTTPException) as error: _locate("alice", GalleryItemKind.IMAGE, "anything.png", board_id=board.board_id) - assert error.value.status_code == status.HTTP_403_FORBIDDEN - assert error.value.detail == "Not authorized to access this board" + assert error.value.status_code == status.HTTP_404_NOT_FOUND + assert error.value.detail == "Board not found" diff --git a/tests/app/routers/test_image_map.py b/tests/app/routers/test_image_map.py index 7fe0531e9c9..90a17c8391e 100644 --- a/tests/app/routers/test_image_map.py +++ b/tests/app/routers/test_image_map.py @@ -1459,7 +1459,7 @@ def test_multiuser_board_scoped_search_requires_read_access( response = client.get("/api/v1/image_map/search", params={"q": "boats", "board_id": board}, headers=user2_headers) - assert response.status_code == 403 + assert response.status_code == 404 # Refused before the query is embedded or ranked. assert image_index_service.embedded_texts == [] assert image_index_service.search_calls == [] diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index b07d76ed357..de622248aab 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -12,7 +12,6 @@ from invokeai.app.api.routers.images import MAX_IMAGE_BATCH_SIZE from invokeai.app.api_app import app from invokeai.app.services.auth.token_service import TokenData -from invokeai.app.services.board_records.board_records_common import BoardRecord from invokeai.app.services.image_records.image_records_common import ImageNamesResult, ImageRecordNotFoundException from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.invoker import Invoker @@ -46,26 +45,13 @@ def test_download_images_from_list(monkeypatch: Any, mock_invoker: Invoker, clie def test_download_images_from_board_id_empty_image_name_list( monkeypatch: Any, mock_invoker: Invoker, client: TestClient ) -> None: - expected_board_name = "test" - - mock_get = MagicMock( - return_value=BoardRecord( - board_id="12345", - board_name=expected_board_name, - user_id="system", - created_at="None", - updated_at="None", - archived=False, - ) - ) - monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_get) + mock_invoker.services.database.queries.boards.insert(board_id="test", board_name="test", user_id="system") prepare_download_images_test(monkeypatch, mock_invoker) response = client.post("/api/v1/images/download", json={"board_id": "test"}) json_response = response.json() assert response.status_code == 202 assert json_response["bulk_download_item_name"] == "test.zip" - mock_get.assert_called_once_with("test") def prepare_download_images_test(monkeypatch: Any, mock_invoker: Invoker) -> None: diff --git a/tests/app/routers/test_media_copy.py b/tests/app/routers/test_media_copy.py index 0d86164d688..b3f31e580e2 100644 --- a/tests/app/routers/test_media_copy.py +++ b/tests/app/routers/test_media_copy.py @@ -214,12 +214,26 @@ def test_copying_into_a_board_you_cannot_write_is_refused( refused = _copy_images(client, user1_token, image_names=[source.image_name], board_id=theirs) missing = _copy_images(client, user1_token, image_names=[source.image_name], board_id="no-such-board") - assert refused.status_code == status.HTTP_403_FORBIDDEN + assert refused.status_code == status.HTTP_404_NOT_FOUND assert missing.status_code == status.HTTP_404_NOT_FOUND # The refusal lands before anything is copied. assert set(mock_invoker.services.image_records.get_image_names().image_names) == before +def test_copying_into_a_board_you_can_only_view_is_refused( + client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str, real_images: DiskImageFileStorage +): + viewable = _create_board(client, user2_token, "Viewable") + client.patch(f"/api/v1/boards/{viewable}", json={"general_access": "viewer"}, headers=_auth(user2_token)) + source = _create_source_image(mock_invoker, _owner_id(client, user1_token)) + before = set(mock_invoker.services.image_records.get_image_names().image_names) + + refused = _copy_images(client, user1_token, image_names=[source.image_name], board_id=viewable) + + assert refused.status_code == status.HTTP_403_FORBIDDEN + assert set(mock_invoker.services.image_records.get_image_names().image_names) == before + + def test_copying_someone_elses_image_is_refused_per_name( client: TestClient, mock_invoker: Invoker, diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 1370dc72f00..fbad3cbc32e 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -248,15 +248,20 @@ def _share_board(client: TestClient, token: str, board_id: str) -> None: assert r.status_code == status.HTTP_201_CREATED -def _share_board_with_user(db: Database, board_id: str, user_id: str) -> None: - """Insert an explicit per-user share row directly into the board_members table.""" +def _share_board_with_user(db: Database, board_id: str, user_id: str, role: str = "viewer") -> None: + """Make the user a member of the board directly: no route grants membership yet.""" with sqlite_cursor(db) as cursor: cursor.execute( - "INSERT OR IGNORE INTO board_members (board_id, user_id, role) VALUES (?, ?, 'viewer')", - (board_id, user_id), + "INSERT OR REPLACE INTO board_members (board_id, user_id, role) VALUES (?, ?, ?)", + (board_id, user_id, role), ) +def _unshare_board_with_user(db: Database, board_id: str, user_id: str) -> None: + with sqlite_cursor(db) as cursor: + cursor.execute("DELETE FROM board_members WHERE board_id = ? AND user_id = ?", (board_id, user_id)) + + def _set_board_visibility(client: TestClient, token: str, board_id: str, visibility: str) -> None: r = client.patch(f"/api/v1/boards/{board_id}", json={"board_visibility": visibility}, headers=_auth(token)) assert r.status_code == status.HTTP_201_CREATED @@ -355,7 +360,7 @@ def test_admin_can_add_image_to_any_board( json={"board_id": board_id, "image_name": "user1-admin-board-img"}, headers=_auth(admin_token), ) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_non_owner_can_add_own_image_to_public_board( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -386,7 +391,7 @@ def test_owner_can_add_image_to_own_board(self, client: TestClient, mock_invoker json={"board_id": board_id, "image_name": "user1-own-board-img"}, headers=_auth(user1_token), ) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_non_owner_cannot_add_other_users_image_to_own_board( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -540,9 +545,9 @@ def test_batch_remove_decides_board_write_access_once_per_name_and_cheaply( the decision is now taken for every name -- caching it per board would let a permission revoked mid-batch keep working until the request ends (see the revocation test below). - Which is only affordable because the decision reads the board *record*: one indexed - SELECT. Through boards.get_dto() it would be six queries -- three of them COUNT - aggregates over the board's contents -- per name, synchronously, on the event loop, for + Which is only affordable because the decision reads the image's owner and the board's access + facts: indexed SELECTs. Through boards.get_dto() it would be six queries -- three of them + COUNT aggregates over the board's contents -- per name, synchronously, on the event loop, for a 1000-name batch. So get_dto must not appear in this path at all. """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") @@ -555,8 +560,8 @@ def test_batch_remove_decides_board_write_access_once_per_name_and_cheaply( get_dto_spy = MagicMock(side_effect=mock_invoker.services.boards.get_dto) monkeypatch.setattr(mock_invoker.services.boards, "get_dto", get_dto_spy) - record_spy = MagicMock(side_effect=mock_invoker.services.board_records.get) - monkeypatch.setattr(mock_invoker.services.board_records, "get", record_spy) + decision_spy = MagicMock(side_effect=mock_invoker.services.board_access.require_image) + monkeypatch.setattr(mock_invoker.services.board_access, "require_image", decision_spy) r = client.post( "/api/v1/board_images/batch/delete", @@ -565,32 +570,36 @@ def test_batch_remove_decides_board_write_access_once_per_name_and_cheaply( ) assert r.status_code == status.HTTP_201_CREATED assert r.json()["removed_images"] == [] - assert record_spy.call_count == len(names) + assert decision_spy.call_count == len(names) assert get_dto_spy.call_count == 0 def test_batch_remove_stops_when_board_write_access_is_revoked_mid_batch( - self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str + self, + client: TestClient, + mock_invoker: Invoker, + mock_sqlite_database: Database, + user1_token: str, + user2_token: str, ): - """A contributor's write access ends the moment the board stops being public. + """An editor's right to remove others' items ends the moment the membership does. - Public boards accept contributions from anyone, so user2 may empty user1's public board. - Nothing about that decision holds for the rest of a 1000-name batch: user1 can make the - board private while it is still running. Caching the first `True` would remove every - remaining name on an answer that is no longer true, and none of it is undone. + As an editor, user2 may empty user1's board. Nothing about that decision holds for the rest + of a 1000-name batch: user1 can revoke the membership while it is still running. Caching the + first `True` would remove every remaining name on an answer that is no longer true, and none + of it is undone. """ - from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardVisibility - user1 = mock_invoker.services.users.get_by_email("user1@test.com") - assert user1 is not None - board_id = _create_board(client, user1_token, "User1 Public Board Revoked") - _set_board_visibility(client, user1_token, board_id, "public") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + board_id = _create_board(client, user1_token, "User1 Board Revoked") + _share_board_with_user(mock_sqlite_database, board_id, user2.user_id, role="editor") names = [f"revoke-rm-{index}" for index in range(3)] for name in names: _save_image(mock_invoker, name, user1.user_id) mock_invoker.services.board_image_records.add_image_to_board(board_id, name) def _revoke_after_first_removal(image_name: str, board_id: str) -> int: - mock_invoker.services.board_records.update(board_id, BoardChanges(board_visibility=BoardVisibility.Private)) + _unshare_board_with_user(mock_sqlite_database, board_id, user2.user_id) # One row removed: a side_effect's return value overrides the mock's return_value, # and the route classifies anything else as a miss. return 1 @@ -753,20 +762,29 @@ def test_batch_routes_report_a_name_whose_board_check_hit_a_storage_error( for name in names: mock_invoker.services.board_image_records.add_image_to_board(board_id, name) - real_get = mock_invoker.services.board_records.get - calls = {"n": 0} - # One decision per name, plus — on the add route only — the up-front check that answers - # a wholly unauthorized request with a 403 before the loop starts. Either way the last - # decision is the one taken for "decide-broken". - failing_call = 3 if route == "add" else 2 + access = mock_invoker.services.board_access + if route == "add": + # One board decision per name, plus the up-front check that answers a wholly + # unauthorized request with a 403 before the loop starts: the third is "decide-broken"'s. + real_get = access.get + calls = {"n": 0} + + def _fail_on_the_last_decision(*args: Any): + calls["n"] += 1 + if calls["n"] == 3: + raise sqlite3.OperationalError("database is locked") + return real_get(*args) + + monkeypatch.setattr(access, "get", _fail_on_the_last_decision) + else: + real_require_image = access.require_image - def _fail_on_the_last_decision(requested_board_id: str): - calls["n"] += 1 - if calls["n"] == failing_call: - raise sqlite3.OperationalError("database is locked") - return real_get(requested_board_id) + def _fail_for_the_broken_name(image_name: str, *args: Any): + if image_name == "decide-broken": + raise sqlite3.OperationalError("database is locked") + return real_require_image(image_name, *args) - monkeypatch.setattr(mock_invoker.services.board_records, "get", _fail_on_the_last_decision) + monkeypatch.setattr(access, "require_image", _fail_for_the_broken_name) mock_invoker.services.board_images.add_image_to_board.reset_mock() mock_invoker.services.board_images.remove_image_from_board.reset_mock() @@ -791,33 +809,40 @@ def _fail_on_the_last_decision(requested_board_id: str): assert body["failed_images"] == ["decide-broken"] def test_batch_remove_only_touches_the_board_it_authorized_against( - self, client: TestClient, mock_invoker: Invoker, monkeypatch: Any, user1_token: str, user2_token: str + self, + client: TestClient, + mock_invoker: Invoker, + mock_sqlite_database: Database, + monkeypatch: Any, + user1_token: str, + user2_token: str, ): """Deciding freshly is not enough — the write has to be scoped to what was decided. The route reads the image's board, authorizes against *that* board, then removes. An unscoped `DELETE ... WHERE image_name = ?` follows the image if it is moved in between, - so a decision taken about a public board could be applied to a private one: user2 is - authorized against public P, user1 moves the image to private Q, and the delete lands on - Q. The predicate belongs on the write. + so a decision taken about one board could be applied to another: user2 is authorized as + an editor of P, user1 moves the image to Q (where user2 has no role), and the delete lands + on Q. The predicate belongs on the write. """ user1 = mock_invoker.services.users.get_by_email("user1@test.com") - assert user1 is not None - public_board = _create_board(client, user1_token, "User1 Public Source") - _set_board_visibility(client, user1_token, public_board, "public") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + source_board = _create_board(client, user1_token, "User1 Edited Source") + _share_board_with_user(mock_sqlite_database, source_board, user2.user_id, role="editor") private_board = _create_board(client, user1_token, "User1 Private Destination") _save_image(mock_invoker, "moving-target", user1.user_id) - mock_invoker.services.board_image_records.add_image_to_board(public_board, "moving-target") + mock_invoker.services.board_image_records.add_image_to_board(source_board, "moving-target") - # The image moves to the private board between the route's board read and its delete. - real_get_dto = mock_invoker.services.images.get_dto + # The image moves to the private board after the decision, before the route's delete. + access = mock_invoker.services.board_access + real_require_image = access.require_image - def _move_after_reading(image_name: str): - dto = real_get_dto(image_name) + def _move_after_deciding(image_name: str, *args: Any) -> None: + real_require_image(image_name, *args) mock_invoker.services.board_image_records.add_image_to_board(private_board, image_name) - return dto - monkeypatch.setattr(mock_invoker.services.images, "get_dto", _move_after_reading) + monkeypatch.setattr(access, "require_image", _move_after_deciding) # The real storage, so the scoping is exercised rather than asserted on a mock. mock_invoker.services.board_images.remove_image_from_board.side_effect = ( mock_invoker.services.board_image_records.remove_image_from_board @@ -975,7 +1000,7 @@ def test_unreadable_board_does_not_read_as_unavailable( # Patched only after the setup above used the real store. user2 is neither admin nor # direct owner, so the decision reaches the board lookup and cannot complete. monkeypatch.setattr( - mock_invoker.services.board_records, + mock_invoker.services.board_access, "get", MagicMock(side_effect=sqlite3.OperationalError("database is locked")), ) @@ -1004,7 +1029,7 @@ def test_vanished_board_still_reads_as_an_ordinary_refusal( mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-read-board-gone") monkeypatch.setattr( - mock_invoker.services.board_records, + mock_invoker.services.board_access, "get", MagicMock(side_effect=BoardRecordNotFoundException), ) @@ -1100,7 +1125,7 @@ def test_owner_can_read_own_image(self, client: TestClient, mock_invoker: Invoke r = client.get("/api/v1/images/i/user1-readable", headers=_auth(user1_token)) # Should not be 403 (may be 404/500 due to missing board_image_records mock) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_admin_can_read_any_image( self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str @@ -1110,7 +1135,7 @@ def test_admin_can_read_any_image( _save_image(mock_invoker, "user1-admin-read", user1.user_id) r = client.get("/api/v1/images/i/user1-admin-read", headers=_auth(admin_token)) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_shared_board_image_readable_by_other_user( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -1127,7 +1152,7 @@ def test_shared_board_image_readable_by_other_user( r = client.get("/api/v1/images/i/shared-board-img", headers=_auth(user2_token)) # Should not be 403 — image is on a shared board - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_explicit_share_grants_image_read_to_member( self, @@ -1152,7 +1177,7 @@ def test_explicit_share_grants_image_read_to_member( _share_board_with_user(mock_sqlite_database, board_id, user2.user_id) r = client.get("/api/v1/images/i/explicit-share-img", headers=_auth(user2_token)) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_board_owner_can_read_contributor_image_on_private_board( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -1199,7 +1224,7 @@ def test_list_images_private_board_rejected_for_non_owner( board_id = _create_board(client, user1_token, "Private Enum Board") r = client.get(f"/api/v1/images/?board_id={board_id}", headers=_auth(user2_token)) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND def test_list_images_shared_board_allowed_for_non_owner( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -1241,15 +1266,15 @@ def test_explicitly_shared_private_board_dto_and_image_names_allowed_for_member( user2_token: str, ): """User2 should be able to fetch the board DTO and board image_names of a private - board explicitly shared with them; a non-member (user1's other boards aside) stays 403.""" + board explicitly shared with them; to a non-member it answers as missing (404).""" user2 = mock_invoker.services.users.get_by_email("user2@test.com") assert user2 is not None board_id = _create_board(client, user1_token, "Explicit Share DTO Board") r = client.get(f"/api/v1/boards/{board_id}", headers=_auth(user2_token)) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND r = client.get(f"/api/v1/boards/{board_id}/image_names", headers=_auth(user2_token)) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND _share_board_with_user(mock_sqlite_database, board_id, user2.user_id) @@ -1266,7 +1291,7 @@ def test_get_image_names_private_board_rejected_for_non_owner( board_id = _create_board(client, user1_token, "Private Names Board") r = client.get(f"/api/v1/images/names?board_id={board_id}", headers=_auth(user2_token)) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND def test_get_image_names_shared_board_allowed_for_non_owner( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -1332,7 +1357,7 @@ def test_owner_can_upload_to_own_shared_board(self, client: TestClient, user1_to headers=_auth(user1_token), ) # Should not be 403 (may fail for other reasons in test env) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_non_owner_can_upload_to_public_board(self, client: TestClient, user1_token: str, user2_token: str): """Public boards allow any authenticated user to upload images.""" @@ -1348,7 +1373,7 @@ def test_non_owner_can_upload_to_public_board(self, client: TestClient, user1_to headers=_auth(user2_token), ) # Should not be 403 (may fail downstream for other reasons in test env) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) class TestImageMutationAuth: @@ -1427,7 +1452,7 @@ def test_owner_can_delete_own_image(self, client: TestClient, mock_invoker: Invo r = client.delete("/api/v1/images/i/user1-delete-me", headers=_auth(user1_token)) # Should not be 403 (may be 200 or 500 depending on file system) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_admin_can_delete_any_image( self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str @@ -1437,13 +1462,13 @@ def test_admin_can_delete_any_image( _save_image(mock_invoker, "user1-admin-delete", user1.user_id) r = client.delete("/api/v1/images/i/user1-admin-delete", headers=_auth(admin_token)) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) - def test_board_owner_can_delete_image_on_own_board( + def test_board_owner_cannot_delete_someone_elses_image_on_their_board( self, client: TestClient, mock_invoker: Invoker, user1_token: str ): - """Board owner should be able to delete images on their board even if - the image's user_id is 'system' (e.g. generated images).""" + """Permanent deletion is the image owner's (and admins'): a board's owner can only remove + someone else's image from the board.""" # Create image owned by "system" (simulates queue-generated image) _save_image(mock_invoker, "system-img-on-board", "system") @@ -1454,7 +1479,14 @@ def test_board_owner_can_delete_image_on_own_board( ) r = client.delete("/api/v1/images/i/system-img-on-board", headers=_auth(user1_token)) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_403_FORBIDDEN + + r = client.post( + "/api/v1/board_images/batch/delete", + json={"image_names": ["system-img-on-board"]}, + headers=_auth(user1_token), + ) + assert r.json()["removed_images"] == ["system-img-on-board"] def test_non_owner_cannot_update_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -1470,6 +1502,36 @@ def test_non_owner_cannot_update_image( ) assert r.status_code == status.HTTP_403_FORBIDDEN + @pytest.mark.parametrize( + ("changes", "expected"), + [ + ({"starred": True}, status.HTTP_200_OK), + ({"image_category": "control"}, status.HTTP_403_FORBIDDEN), + ({"starred": True, "is_intermediate": True}, status.HTTP_403_FORBIDDEN), + ], + ) + def test_an_editor_may_star_someone_elses_image_but_change_nothing_else( + self, + client: TestClient, + mock_invoker: Invoker, + mock_sqlite_database: Database, + user1_token: str, + user2_token: str, + changes: dict[str, Any], + expected: int, + ): + user1 = mock_invoker.services.users.get_by_email("user1@test.com") + user2 = mock_invoker.services.users.get_by_email("user2@test.com") + assert user1 is not None and user2 is not None + board_id = _create_board(client, user1_token, "User1 Edited Board") + _share_board_with_user(mock_sqlite_database, board_id, user2.user_id, role="editor") + _save_image(mock_invoker, "user1-edited", user1.user_id) + mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-edited") + + r = client.patch("/api/v1/images/i/user1-edited", json=changes, headers=_auth(user2_token)) + + assert r.status_code == expected + def test_non_owner_cannot_star_image( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): @@ -1528,7 +1590,7 @@ def test_star_reports_a_name_whose_board_lookup_hit_a_storage_error( # Patched only after the setup above used the real store. user2 is neither admin nor # direct owner, so the decision reaches the board lookup and cannot complete. monkeypatch.setattr( - mock_invoker.services.board_records, + mock_invoker.services.board_access, "get", MagicMock(side_effect=sqlite3.OperationalError("database is locked")), ) @@ -1564,7 +1626,7 @@ def test_star_still_skips_a_name_whose_board_is_positively_gone( mock_invoker.services.board_image_records.add_image_to_board(board_id, "user1-board-gone") monkeypatch.setattr( - mock_invoker.services.board_records, + mock_invoker.services.board_access, "get", MagicMock(side_effect=BoardRecordNotFoundException), ) @@ -1603,10 +1665,10 @@ def test_non_owner_cannot_batch_delete_image( assert body["deleted_images"] == [] mock_invoker.services.image_records.get("user1-batch-del") - def test_non_owner_can_delete_image_from_public_board( + def test_non_owner_cannot_delete_image_from_a_board_everyone_can_contribute_to( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str ): - """Public-board semantics promise delete access to images contained in the board.""" + """Contributing lets everyone add their own media, not delete anyone else's.""" public_board_id = _create_board(client, user1_token, "User1 Public Delete Board") _set_board_visibility(client, user1_token, public_board_id, "public") @@ -1615,18 +1677,12 @@ def test_non_owner_can_delete_image_from_public_board( _save_image(mock_invoker, "user1-public-delete", user1.user_id) mock_invoker.services.board_image_records.add_image_to_board(public_board_id, "user1-public-delete") - # The delete route no longer swallows service failures, so the test env needs - # working urls/image_files services for the deletion to actually succeed. - mock_invoker.services.urls = MagicMock() - mock_invoker.services.urls.get_image_url.return_value = "http://localhost/img.png" - mock_invoker.services.image_files = MagicMock() - r = client.delete( "/api/v1/images/i/user1-public-delete", headers=_auth(user2_token), ) - assert r.status_code == status.HTTP_200_OK - assert r.json()["deleted_images"] == ["user1-public-delete"] + assert r.status_code == status.HTTP_403_FORBIDDEN + assert mock_invoker.services.image_records.exists("user1-public-delete") def test_clear_intermediates_non_admin_forbidden(self, client: TestClient, user1_token: str): r = client.delete("/api/v1/images/intermediates", headers=_auth(user1_token)) @@ -2149,7 +2205,7 @@ def test_recall_own_image_allowed(self, client: TestClient, mock_invoker: Invoke headers=_auth(user1_token), ) # Should not be 403 (may fail downstream for other reasons, e.g. model not found) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_recall_shared_board_image_allowed( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -2168,7 +2224,7 @@ def test_recall_shared_board_image_allowed( json={"ip_adapters": [{"model_name": "some-ip-adapter", "image_name": "shared-recall-img"}]}, headers=_auth(user2_token), ) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_recall_explicitly_shared_board_image_allowed_for_member( self, @@ -2202,7 +2258,7 @@ def test_recall_explicitly_shared_board_image_allowed_for_member( json={"ip_adapters": [{"model_name": "some-ip-adapter", "image_name": "explicit-share-recall-img"}]}, headers=_auth(user2_token), ) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) def test_recall_admin_can_reference_any_image( self, client: TestClient, mock_invoker: Invoker, admin_token: str, user1_token: str @@ -2217,7 +2273,7 @@ def test_recall_admin_can_reference_any_image( json={"control_layers": [{"model_name": "some-controlnet", "image_name": "admin-recall-img"}]}, headers=_auth(admin_token), ) - assert r.status_code != status.HTTP_403_FORBIDDEN + assert r.status_code not in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) # =========================================================================== @@ -2550,7 +2606,7 @@ def test_bulk_download_by_board_rejected_for_private_board( json={"board_id": board_id}, headers=_auth(user2_token), ) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND def test_bulk_download_by_shared_board_allowed( self, client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str @@ -3577,7 +3633,7 @@ def test_list_private_board_forbidden_for_non_owner( r = client.get( "/api/v1/images/", params={"board_id": seeded_boards["private_board_id"]}, headers=_auth(user2_token) ) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND def test_list_shared_board_readable_by_non_owner( self, client: TestClient, seeded_boards: dict[str, str], user2_token: str @@ -3612,4 +3668,4 @@ def test_names_private_board_forbidden_for_non_owner( params={"board_id": seeded_boards["private_board_id"]}, headers=_auth(user2_token), ) - assert r.status_code == status.HTTP_403_FORBIDDEN + assert r.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/app/routers/test_upload_events.py b/tests/app/routers/test_upload_events.py index 47087209583..500844df078 100644 --- a/tests/app/routers/test_upload_events.py +++ b/tests/app/routers/test_upload_events.py @@ -14,7 +14,6 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers import videos from invokeai.app.api_app import app -from invokeai.app.services.board_records.board_records_common import BoardRecord, BoardVisibility from invokeai.app.services.events.events_common import ImageUploadedEvent, VideoUploadedEvent from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.images.images_common import ImageDTO @@ -35,18 +34,6 @@ def __init__(self, invoker) -> None: self.invoker = invoker -def _board(board_visibility: BoardVisibility) -> BoardRecord: - return BoardRecord( - board_id="board-1", - board_name="Uploads", - user_id="owner-1", - created_at="None", - updated_at="None", - archived=False, - board_visibility=board_visibility, - ) - - def _image_dto(is_intermediate: bool) -> ImageDTO: return ImageDTO( image_name="upload.png", @@ -75,9 +62,8 @@ def _prepare_image_upload(monkeypatch: Any, mock_invoker: Invoker, is_intermedia mock_deps = MockApiDependencies(mock_invoker) mock_invoker.services.image_moves = MagicMock() mock_invoker.services.image_moves.is_maintenance_active.return_value = False - monkeypatch.setattr( - mock_invoker.services.board_records, "get", MagicMock(return_value=_board(BoardVisibility.Public)) - ) + if not mock_invoker.services.database.queries.boards.exists("board-1"): + mock_invoker.services.database.queries.boards.insert(board_id="board-1", board_name="Uploads", user_id="system") monkeypatch.setattr(mock_invoker.services.images, "create", MagicMock(return_value=_image_dto(is_intermediate))) for target in ( "invokeai.app.api.routers.images.ApiDependencies", @@ -96,7 +82,7 @@ def _upload_events(mock_invoker: Invoker) -> list[ImageUploadedEvent | VideoUplo ] -def test_image_upload_announces_the_upload_with_its_board_visibility( +def test_image_upload_announces_the_upload_with_its_board( monkeypatch: Any, mock_invoker: Invoker, client: TestClient ) -> None: _prepare_image_upload(monkeypatch, mock_invoker, is_intermediate=False) @@ -115,9 +101,9 @@ def test_image_upload_announces_the_upload_with_its_board_visibility( assert event.image_name == "upload.png" assert event.image_category == ImageCategory.USER assert event.board_id == "board-1" - assert event.board_owner_id == "owner-1" - assert event.board_visibility == BoardVisibility.Public assert event.user_id == "system" + # Who may see the board is decided where the event is routed, not carried on it. + assert set(event.model_dump()) == {"timestamp", "user_id", "board_id", "image_name", "image_category"} def test_image_upload_without_a_board_is_announced_with_no_board( @@ -127,6 +113,8 @@ def test_image_upload_without_a_board_is_announced_with_no_board( mock_invoker.services.images.create.return_value = _image_dto(is_intermediate=False).model_copy( update={"board_id": None} ) + board_access_get = MagicMock() + monkeypatch.setattr(mock_invoker.services.board_access, "get", board_access_get) response = client.post( "/api/v1/images/upload", @@ -135,12 +123,10 @@ def test_image_upload_without_a_board_is_announced_with_no_board( ) assert response.status_code == 201 - mock_invoker.services.board_records.get.assert_not_called() + board_access_get.assert_not_called() events = _upload_events(mock_invoker) assert len(events) == 1 assert events[0].board_id is None - assert events[0].board_owner_id is None - assert events[0].board_visibility is None def test_failed_image_creation_is_a_mapped_500_with_no_announcement( @@ -202,52 +188,6 @@ def test_failed_board_attach_is_announced_without_the_board( events = _upload_events(mock_invoker) assert len(events) == 1 assert events[0].board_id is None - assert events[0].board_owner_id is None - assert events[0].board_visibility is None - assert events[0].shared_user_ids == [] - - -def test_private_board_upload_carries_its_share_recipients( - monkeypatch: Any, mock_invoker: Invoker, client: TestClient -) -> None: - """A private board's viewers are named on the event; the socket layer never queries storage.""" - _prepare_image_upload(monkeypatch, mock_invoker, is_intermediate=False) - monkeypatch.setattr( - mock_invoker.services.board_records, "get", MagicMock(return_value=_board(BoardVisibility.Private)) - ) - monkeypatch.setattr( - mock_invoker.services.board_records, "get_shared_user_ids", MagicMock(return_value=["viewer-1", "viewer-2"]) - ) - - response = client.post( - "/api/v1/images/upload", - params={"image_category": "user", "is_intermediate": False, "board_id": "board-1"}, - files={"file": ("upload.png", _png_bytes(), "image/png")}, - ) - - assert response.status_code == 201 - events = _upload_events(mock_invoker) - assert len(events) == 1 - assert events[0].shared_user_ids == ["viewer-1", "viewer-2"] - - -def test_upload_to_a_board_everyone_can_see_does_not_enumerate_shares( - monkeypatch: Any, mock_invoker: Invoker, client: TestClient -) -> None: - """Shared/Public uploads are broadcast, so paying for the share lookup would be waste.""" - _prepare_image_upload(monkeypatch, mock_invoker, is_intermediate=False) - get_shared_user_ids = MagicMock(return_value=["viewer-1"]) - monkeypatch.setattr(mock_invoker.services.board_records, "get_shared_user_ids", get_shared_user_ids) - - response = client.post( - "/api/v1/images/upload", - params={"image_category": "user", "is_intermediate": False, "board_id": "board-1"}, - files={"file": ("upload.png", _png_bytes(), "image/png")}, - ) - - assert response.status_code == 201 - get_shared_user_ids.assert_not_called() - assert _upload_events(mock_invoker)[0].shared_user_ids == [] def _video_dto() -> VideoDTO: @@ -273,9 +213,8 @@ def _prepare_video_upload(monkeypatch: Any, mock_invoker: Invoker) -> None: mock_deps = MockApiDependencies(mock_invoker) monkeypatch.setattr(mock_invoker.services, "videos", MagicMock(), raising=False) mock_invoker.services.videos.create.return_value = _video_dto() - monkeypatch.setattr( - mock_invoker.services.board_records, "get", MagicMock(return_value=_board(BoardVisibility.Shared)) - ) + if not mock_invoker.services.database.queries.boards.exists("board-1"): + mock_invoker.services.database.queries.boards.insert(board_id="board-1", board_name="Uploads", user_id="system") for target in ( "invokeai.app.api.routers.videos.ApiDependencies", "invokeai.app.api.routers._access.ApiDependencies", @@ -298,7 +237,7 @@ def _post_video(client: TestClient, is_intermediate: bool, video_category: str = ) -def test_video_upload_announces_the_upload_with_its_board_visibility( +def test_video_upload_announces_the_upload_with_its_board( monkeypatch: Any, mock_invoker: Invoker, client: TestClient ) -> None: _prepare_video_upload(monkeypatch, mock_invoker) @@ -313,8 +252,6 @@ def test_video_upload_announces_the_upload_with_its_board_visibility( assert isinstance(event, VideoUploadedEvent) assert event.video_name == "upload.mp4" assert event.board_id == "board-1" - assert event.board_owner_id == "owner-1" - assert event.board_visibility == BoardVisibility.Shared assert event.user_id == "system" diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py index a72d10cc51b..7c99f1b4ce2 100644 --- a/tests/app/routers/test_videos_multiuser.py +++ b/tests/app/routers/test_videos_multiuser.py @@ -179,7 +179,7 @@ def test_list_video_dtos_forbidden_for_other_users_private_board( f"/api/v1/videos/?board_id={user1_private_board}", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_video_names_forbidden_for_other_users_private_board( @@ -189,7 +189,7 @@ def test_get_video_names_forbidden_for_other_users_private_board( f"/api/v1/videos/names?board_id={user1_private_board}", headers={"Authorization": f"Bearer {user2_token}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_404_NOT_FOUND def test_owner_can_list_videos_on_their_private_board(client: TestClient, user1_private_board: str, user1_token: str): @@ -336,7 +336,7 @@ def test_unreadable_board_does_not_read_as_unavailable_for_videos( mock_invoker.services.video_records.get_user_id.return_value = "someone-else" mock_invoker.services.board_video_records.get_board_for_video.return_value = "board-1" - mock_invoker.services.board_records.get = MagicMock(side_effect=sqlite3.OperationalError("database is locked")) + mock_invoker.services.board_access.get = MagicMock(side_effect=sqlite3.OperationalError("database is locked")) # The storage error leaves the route uncaught, which is a 500 in production; the test client # re-raises unhandled server exceptions instead of rendering them. Either way the one thing @@ -1010,57 +1010,49 @@ def test_remove_video_from_board_succeeds_for_board_owner_of_non_owned_video( ): """user1 owns the board; user2 owns the video sitting on it. user1 must be able to detach the foreign video from their board even though they are not the video owner.""" - from invokeai.app.services.board_records.board_records_common import BoardVisibility - user1 = mock_invoker.services.users.get_by_email("user1@test.com") user2 = mock_invoker.services.users.get_by_email("user2@test.com") assert user1 is not None and user2 is not None + board_id = mock_invoker.services.board_records.save("User1 board", user1.user_id).board_id mock_invoker.services.video_records.get_user_id.return_value = user2.user_id - mock_invoker.services.board_video_records.get_board_for_video.return_value = "user1-board" - - # _assert_board_write_access reads the lightweight board record to check ownership/visibility. - fake_board = MagicMock() - fake_board.user_id = user1.user_id - fake_board.board_visibility = BoardVisibility.Private - with patch.object(mock_invoker.services.board_records, "get", return_value=fake_board): - response = client.request( - "DELETE", - "/api/v1/videos/board", - json={"video_name": "stranded.mp4"}, - headers={"Authorization": f"Bearer {user1_token}"}, - ) + mock_invoker.services.board_video_records.get_board_for_video.return_value = board_id + + response = client.request( + "DELETE", + "/api/v1/videos/board", + json={"video_name": "stranded.mp4"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) assert response.status_code == status.HTTP_200_OK mock_invoker.services.board_video_records.remove_video_from_board.assert_called_once_with(video_name="stranded.mp4") -def test_remove_video_from_board_rejects_third_party( - client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str +@pytest.mark.parametrize("general_access", ["restricted", "viewer", "contributor"]) +def test_remove_video_from_board_rejects_anyone_below_editor( + client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str, general_access: str ): - """A user who is neither the video owner nor a board write-access holder must be - rejected — the relaxed path is a stranded-contributor escape hatch, not an open door.""" - from invokeai.app.services.board_records.board_records_common import BoardVisibility + """Removing someone else's video from a board takes an editor: a viewer or contributor may not, and on a board + they cannot open the answer is a refusal all the same.""" + from invokeai.app.services.board_access.board_access_common import GeneralAccess + from invokeai.app.services.board_records.board_records_common import BoardChanges admin = mock_invoker.services.users.get_by_email("admin@test.com") user1 = mock_invoker.services.users.get_by_email("user1@test.com") assert admin is not None and user1 is not None + board_id = mock_invoker.services.board_records.save("User1 board", user1.user_id).board_id + mock_invoker.services.board_records.update(board_id, BoardChanges(general_access=GeneralAccess(general_access))) - # Video is owned by admin; board is owned by user1 and is Private. + # The video is the admin's, on user1's board; user2 has only what general access gives. mock_invoker.services.video_records.get_user_id.return_value = admin.user_id - mock_invoker.services.board_video_records.get_board_for_video.return_value = "user1-board" - - fake_board = MagicMock() - fake_board.user_id = user1.user_id - fake_board.board_visibility = BoardVisibility.Private - - # user2 has no claim to either resource. - with patch.object(mock_invoker.services.board_records, "get", return_value=fake_board): - response = client.request( - "DELETE", - "/api/v1/videos/board", - json={"video_name": "not_mine.mp4"}, - headers={"Authorization": f"Bearer {user2_token}"}, - ) + mock_invoker.services.board_video_records.get_board_for_video.return_value = board_id + + response = client.request( + "DELETE", + "/api/v1/videos/board", + json={"video_name": "not_mine.mp4"}, + headers={"Authorization": f"Bearer {user2_token}"}, + ) assert response.status_code == status.HTTP_403_FORBIDDEN mock_invoker.services.board_video_records.remove_video_from_board.assert_not_called() diff --git a/tests/app/services/auth/test_data_isolation.py b/tests/app/services/auth/test_data_isolation.py index 8b273400534..848d3d78073 100644 --- a/tests/app/services/auth/test_data_isolation.py +++ b/tests/app/services/auth/test_data_isolation.py @@ -13,6 +13,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app +from invokeai.app.services.board_access.board_access_common import BoardViewer from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.pagination import SQLiteDirection @@ -94,10 +95,14 @@ def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invo # Create board for user1 board_service = mock_invoker.services.boards - user1_board = board_service.create(board_name="User 1 Board", user_id=user1_id) + user1_board = board_service.create( + board_name="User 1 Board", user_id=user1_id, viewer=BoardViewer(user_id=user1_id, is_admin=False) + ) # Create board for user2 - user2_board = board_service.create(board_name="User 2 Board", user_id=user2_id) + user2_board = board_service.create( + board_name="User 2 Board", user_id=user2_id, viewer=BoardViewer(user_id=user2_id, is_admin=False) + ) # User1 should only see their board user1_boards = board_service.get_many( @@ -140,7 +145,11 @@ def test_user_cannot_access_other_user_board_directly(self, mock_invoker: Invoke user2 = user_service.create(user2_data) # User1 creates a board - user1_board = board_service.create(board_name="User 1 Private Board", user_id=user1.user_id) + user1_board = board_service.create( + board_name="User 1 Private Board", + user_id=user1.user_id, + viewer=BoardViewer(user_id=user1.user_id, is_admin=False), + ) # User2 tries to access user1's board # The get method should check ownership @@ -174,10 +183,14 @@ def test_admin_can_see_all_boards(self, mock_invoker: Invoker): user = user_service.create(user_data) # User creates a board - board_service.create(board_name="User Board", user_id=user.user_id) + board_service.create( + board_name="User Board", user_id=user.user_id, viewer=BoardViewer(user_id=user.user_id, is_admin=False) + ) # Admin creates a board - board_service.create(board_name="Admin Board", user_id=admin.user_id) + board_service.create( + board_name="Admin Board", user_id=admin.user_id, viewer=BoardViewer(user_id=admin.user_id, is_admin=True) + ) # Admin should be able to get all boards (implementation dependent) # Note: Current implementation may not have admin override for board listing @@ -281,7 +294,9 @@ def test_shared_board_access(self, mock_invoker: Invoker): user_service.create(user2_data) # User1 creates a board - board_service.create(board_name="Shared Board", user_id=user1.user_id) + board_service.create( + board_name="Shared Board", user_id=user1.user_id, viewer=BoardViewer(user_id=user1.user_id, is_admin=False) + ) # User1 shares the board with user2 # (This functionality is not yet implemented) @@ -334,33 +349,25 @@ def test_regular_user_cannot_list_all_users(self, mock_invoker: Invoker): class TestDataIntegrity: """Tests for data integrity in multi-user scenarios.""" - def test_user_deletion_cascades_to_owned_data(self, mock_invoker: Invoker): - """Test that deleting a user also deletes their owned data.""" + def test_a_deleted_users_boards_stay_for_admins(self, mock_invoker: Invoker): + """Deleting an account takes its projects, but its boards and their media stay, for admins, in the Library.""" user_service = mock_invoker.services.users board_service = mock_invoker.services.boards - - # Create user - user_data = UserCreateRequest( - email="deleteme@example.com", display_name="Delete Me", password="TestPass123", is_admin=False + user = user_service.create( + UserCreateRequest(email="deleteme@example.com", display_name="Delete Me", password="TestPass123") + ) + project = mock_invoker.services.project_records.create(user.user_id, "Project", {}) + board = board_service.create( + board_name="My Board", + user_id=user.user_id, + project_id=project.project_id, + viewer=BoardViewer(user_id=user.user_id, is_admin=False), ) - user = user_service.create(user_data) - - # User creates a board - board = board_service.create(board_name="My Board", user_id=user.user_id) - # Delete user user_service.delete(user.user_id) - # Board should be deleted too (CASCADE in database) - # Note: get_dto doesn't take user_id parameter, it gets the board by ID only - # We'll check that it raises an exception or returns None after cascade delete - try: - board_service.get_dto(board_id=board.board_id) - # If we get here, the board wasn't deleted - this is a failure - raise AssertionError("Board should have been deleted by CASCADE") - except Exception: - # Expected - board was deleted by CASCADE - pass + kept = board_service.get_dto(board.board_id, viewer=BoardViewer(user_id="admin", is_admin=True)) + assert (kept.board_name, kept.project_id) == ("My Board", None) def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invoker): """Test that concurrent operations from different users maintain data isolation. @@ -383,8 +390,12 @@ def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invok user2 = user_service.create(user2_data) # Both users create boards - user1_board = board_service.create(board_name="User 1 Board", user_id=user1.user_id) - user2_board = board_service.create(board_name="User 2 Board", user_id=user2.user_id) + user1_board = board_service.create( + board_name="User 1 Board", user_id=user1.user_id, viewer=BoardViewer(user_id=user1.user_id, is_admin=False) + ) + user2_board = board_service.create( + board_name="User 2 Board", user_id=user2.user_id, viewer=BoardViewer(user_id=user2.user_id, is_admin=False) + ) # Verify isolation is maintained user1_boards = board_service.get_many( diff --git a/tests/app/services/board_access/test_board_access.py b/tests/app/services/board_access/test_board_access.py new file mode 100644 index 00000000000..22724245f9f --- /dev/null +++ b/tests/app/services/board_access/test_board_access.py @@ -0,0 +1,279 @@ +"""What each role may do to a board and to the items on it, decided over a real database.""" + +from typing import Optional + +import pytest +from sqlalchemy import insert + +from invokeai.app.services.board_access.board_access_common import ( + BoardAccess, + BoardAccessDeniedError, + BoardAction, + BoardAudience, + BoardRole, + GeneralAccess, + ItemAccessDeniedError, + ItemAction, +) +from invokeai.app.services.board_records.board_records_common import ( + BoardChanges, + BoardRecordNotFoundException, + BoardRecordOrderBy, +) +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.boards import board_images, board_members +from invokeai.app.services.shared.database.schema.images import images +from invokeai.app.services.shared.database.schema.projects import projects +from invokeai.app.services.shared.database.schema.users import users +from invokeai.app.services.shared.pagination import SQLiteDirection + +OWNER, OTHER = "owner", "other" + +# What each role may do, written out from the role table rather than read from the code under test. +EXPECTED: dict[BoardRole, set[BoardAction]] = { + BoardRole.Viewer: {BoardAction.Read}, + BoardRole.Contributor: {BoardAction.Read, BoardAction.AddMedia}, + BoardRole.Editor: { + BoardAction.Read, + BoardAction.AddMedia, + BoardAction.ManageItems, + BoardAction.Rename, + BoardAction.SetCover, + BoardAction.Archive, + }, + BoardRole.Owner: set(BoardAction), +} + + +@pytest.mark.parametrize("role", list(BoardRole)) +def test_each_role_allows_exactly_its_actions(role: BoardRole) -> None: + access = BoardAccess(role=role, is_admin=False, is_inbox=False) + + assert set(access.allowed_actions()) == EXPECTED[role] + + +def test_admins_may_do_everything_but_manage_an_inbox_apart_from_its_project() -> None: + assert set(BoardAccess(role=None, is_admin=True, is_inbox=False).allowed_actions()) == set(BoardAction) + assert set(BoardAccess(role=None, is_admin=True, is_inbox=True).allowed_actions()) == { + BoardAction.Read, + BoardAction.AddMedia, + BoardAction.ManageItems, + BoardAction.SetCover, + } + + +def test_admin_override_is_reported_only_when_admin_rights_add_to_the_role() -> None: + assert BoardAccess(role=BoardRole.Viewer, is_admin=True, is_inbox=False).admin_override + assert not BoardAccess(role=BoardRole.Owner, is_admin=True, is_inbox=False).admin_override + assert not BoardAccess(role=BoardRole.Viewer, is_admin=False, is_inbox=False).admin_override + + +@pytest.fixture +def board_id(mock_invoker: Invoker, mock_sqlite_database: Database) -> str: + with mock_sqlite_database.begin(write=True) as conn: + conn.execute( + insert(users), + [{"user_id": user, "email": f"{user}@test.com", "password_hash": "-"} for user in (OWNER, OTHER)], + ) + return mock_invoker.services.board_records.save("Board", OWNER).board_id + + +def _grant(database: Database, board_id: str, role: BoardRole) -> None: + with database.begin(write=True) as conn: + conn.execute(insert(board_members).values(board_id=board_id, user_id=OTHER, role=role.value)) + + +def _set_general_access(invoker: Invoker, board_id: str, general_access: GeneralAccess) -> None: + invoker.services.board_records.update(board_id, BoardChanges(general_access=general_access)) + + +@pytest.mark.parametrize( + ("member_role", "general_access", "expected"), + [ + (None, GeneralAccess.Restricted, None), + (None, GeneralAccess.Viewer, BoardRole.Viewer), + (BoardRole.Viewer, GeneralAccess.Contributor, BoardRole.Contributor), + (BoardRole.Editor, GeneralAccess.Viewer, BoardRole.Editor), + ], +) +def test_a_caller_gets_the_higher_of_their_membership_and_general_access( + mock_invoker: Invoker, + mock_sqlite_database: Database, + board_id: str, + member_role: Optional[BoardRole], + general_access: GeneralAccess, + expected: Optional[BoardRole], +) -> None: + if member_role is not None: + _grant(mock_sqlite_database, board_id, member_role) + _set_general_access(mock_invoker, board_id, general_access) + + assert mock_invoker.services.board_access.get(board_id, OTHER, False).role == expected + assert mock_invoker.services.board_access.get(board_id, OWNER, False).role == BoardRole.Owner + + +def test_a_refusal_says_whether_the_caller_can_see_the_board( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + access = mock_invoker.services.board_access + + with pytest.raises(BoardAccessDeniedError) as unseen: + access.require(board_id, OTHER, False, BoardAction.Read) + _grant(mock_sqlite_database, board_id, BoardRole.Viewer) + with pytest.raises(BoardAccessDeniedError) as seen: + access.require(board_id, OTHER, False, BoardAction.AddMedia) + + assert (unseen.value.readable, seen.value.readable) == (False, True) + with pytest.raises(BoardRecordNotFoundException): + access.require("missing", OTHER, False, BoardAction.Read) + + +def test_a_projects_inbox_is_managed_through_its_project( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + with mock_sqlite_database.begin(write=True) as conn: + conn.execute(insert(projects).values(project_id="p", user_id=OWNER, name="P", data="{}", board_id=board_id)) + + inbox = mock_invoker.services.board_access.get(board_id, OWNER, False) + + assert inbox.is_inbox + assert set(inbox.allowed_actions()) == { + BoardAction.Read, + BoardAction.AddMedia, + BoardAction.ManageItems, + BoardAction.SetCover, + } + + +def _put_image(database: Database, name: str, owner: str, board_id: Optional[str]) -> None: + with database.begin(write=True) as conn: + conn.execute( + insert(images).values( + image_name=name, image_origin="internal", image_category="general", width=1, height=1, user_id=owner + ) + ) + if board_id is not None: + conn.execute(insert(board_images).values(board_id=board_id, image_name=name)) + + +def _allowed_item_actions(invoker: Invoker, name: str, user_id: str, is_admin: bool = False) -> set[ItemAction]: + allowed = set() + for action in ItemAction: + try: + invoker.services.board_access.require_image(name, user_id, is_admin, action) + except ItemAccessDeniedError: + continue + allowed.add(action) + return allowed + + +@pytest.mark.parametrize( + ("role", "expected"), + [ + (None, set()), + (BoardRole.Viewer, {ItemAction.Read}), + (BoardRole.Contributor, {ItemAction.Read}), + (BoardRole.Editor, {ItemAction.Read, ItemAction.RemoveFromBoard, ItemAction.Star}), + ], +) +def test_someone_elses_image_is_managed_by_role_and_deleted_by_no_member( + mock_invoker: Invoker, + mock_sqlite_database: Database, + board_id: str, + role: Optional[BoardRole], + expected: set[ItemAction], +) -> None: + _put_image(mock_sqlite_database, "theirs.png", OWNER, board_id) + if role is not None: + _grant(mock_sqlite_database, board_id, role) + + assert _allowed_item_actions(mock_invoker, "theirs.png", OTHER) == expected + + +def test_an_items_owner_keeps_every_right_to_it_on_a_board_they_cannot_see( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + _put_image(mock_sqlite_database, "mine.png", OTHER, board_id) + + assert _allowed_item_actions(mock_invoker, "mine.png", OTHER) == set(ItemAction) + # The board's owner only manages it there. + assert _allowed_item_actions(mock_invoker, "mine.png", OWNER) == { + ItemAction.Read, + ItemAction.RemoveFromBoard, + ItemAction.Star, + } + assert _allowed_item_actions(mock_invoker, "mine.png", OWNER, is_admin=True) == set(ItemAction) + + +def test_an_image_on_no_board_is_its_owners_alone( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + _put_image(mock_sqlite_database, "loose.png", OWNER, None) + + assert _allowed_item_actions(mock_invoker, "loose.png", OTHER) == set() + + +def test_the_audience_is_everyone_or_the_owner_and_members( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + access = mock_invoker.services.board_access + _grant(mock_sqlite_database, board_id, BoardRole.Viewer) + + assert access.audience(board_id) == BoardAudience(everyone=False, user_ids=(OWNER, OTHER)) + _set_general_access(mock_invoker, board_id, GeneralAccess.Viewer) + assert access.audience(board_id) == BoardAudience(everyone=True, user_ids=()) + assert access.audience("missing") is None + + +@pytest.mark.parametrize("member_role", [None, BoardRole.Viewer, BoardRole.Editor]) +@pytest.mark.parametrize("general_access", list(GeneralAccess)) +def test_listings_show_exactly_the_boards_the_service_lets_a_caller_read( + mock_invoker: Invoker, + mock_sqlite_database: Database, + board_id: str, + member_role: Optional[BoardRole], + general_access: GeneralAccess, +) -> None: + """The SQL that filters listings and the rules that decide single boards are two copies of one rule.""" + if member_role is not None: + _grant(mock_sqlite_database, board_id, member_role) + _set_general_access(mock_invoker, board_id, general_access) + + listed = { + board.board_id + for board in mock_invoker.services.board_records.get_all( + OTHER, False, BoardRecordOrderBy.CreatedAt, SQLiteDirection.Descending, False + ) + } + readable = mock_invoker.services.board_access.get(board_id, OTHER, False).allows(BoardAction.Read) + + assert (board_id in listed) == readable + + +def test_a_membership_gives_its_role_to_its_member_alone( + mock_invoker: Invoker, mock_sqlite_database: Database, board_id: str +) -> None: + """With members on the board, an account that is not one gets nothing, and each member gets their own role.""" + third = "third" + with mock_sqlite_database.begin(write=True) as conn: + conn.execute(insert(users).values(user_id=third, email="third@test.com", password_hash="-")) + conn.execute(insert(board_members).values(board_id=board_id, user_id=OTHER, role=BoardRole.Editor.value)) + access = mock_invoker.services.board_access + + assert access.get(board_id, OTHER, False).role is BoardRole.Editor + assert access.get(board_id, third, False).role is None + with pytest.raises(BoardAccessDeniedError) as refused: + access.require(board_id, third, False, BoardAction.Read) + assert not refused.value.readable + + with mock_sqlite_database.begin(write=True) as conn: + conn.execute(insert(board_members).values(board_id=board_id, user_id=third, role=BoardRole.Viewer.value)) + roles = { + user: {dto.board_id: dto.my_role for dto in _listing(mock_invoker, user)}[board_id] for user in (OTHER, third) + } + assert roles == {OTHER: BoardRole.Editor, third: BoardRole.Viewer} + + +def _listing(invoker: Invoker, user_id: str) -> list: + return invoker.services.boards.get_all(user_id, False, BoardRecordOrderBy.CreatedAt, SQLiteDirection.Descending) diff --git a/tests/app/services/boards/test_board_storage.py b/tests/app/services/boards/test_board_storage.py index fa4d849c3d0..77af8fbeb2f 100644 --- a/tests/app/services/boards/test_board_storage.py +++ b/tests/app/services/boards/test_board_storage.py @@ -3,8 +3,10 @@ import pytest from sqlalchemy import insert, select +from invokeai.app.services.board_access.board_access_common import GeneralAccess from invokeai.app.services.board_image_records.board_image_records_default import BoardImageRecordStorage from invokeai.app.services.board_records.board_records_common import ( + VISIBILITY_BY_GENERAL_ACCESS, BoardChanges, BoardRecordInboxException, BoardRecordNotFoundException, @@ -171,9 +173,6 @@ def listed(*, is_admin: bool, include_archived: bool = False) -> set[str]: assert listed(is_admin=False) == {"Own", "Shared with Alice", "Shared", "Public"} assert listed(is_admin=False, include_archived=True) == {"Own", "Shared with Alice", "Shared", "Public", "Archived"} assert listed(is_admin=True) == {"Own", "Shared with Alice", "Shared with Carol", "Shared", "Public", "Private"} - assert board_records.is_board_shared_with_user(shared_with_carol, CAROL) is True - assert board_records.is_board_shared_with_user(shared_with_carol, ALICE) is False - assert board_records.get_shared_user_ids(shared_with_carol) == [CAROL] def _insert_boards(database: Database, boards: list[tuple[str, str, str]]) -> None: @@ -263,10 +262,10 @@ def test_a_project_board_keeps_what_its_project_owns(database: Database, board_r board_records.update(board.board_id, changes) updated = board_records.update(board.board_id, BoardChanges(cover_image_name="cover.png")) - assert (updated.board_name, updated.archived, updated.board_visibility) == ( + assert (updated.board_name, updated.archived, updated.general_access) == ( "Project", False, - BoardVisibility.Private, + GeneralAccess.Restricted, ) assert updated.cover_image_name == "cover.png" with pytest.raises(BoardRecordNotFoundException): @@ -458,4 +457,4 @@ def test_board_visibility_is_stored_as_the_matching_general_access( ) assert stored == general_access - assert board_records.get("stored").board_visibility == visibility + assert VISIBILITY_BY_GENERAL_ACCESS[board_records.get("stored").general_access] == visibility diff --git a/tests/app/services/boards/test_boards_default.py b/tests/app/services/boards/test_boards_default.py index a2791f2ee71..2be328644cc 100644 --- a/tests/app/services/boards/test_boards_default.py +++ b/tests/app/services/boards/test_boards_default.py @@ -4,13 +4,15 @@ import pytest -from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy +from invokeai.app.services.board_access.board_access_common import BoardViewer, GeneralAccess +from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy from invokeai.app.services.board_records.board_records_default import BoardRecordStorage from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.errors import LockTimeoutError from invokeai.app.services.shared.database.queries.boards import BoardQueries from invokeai.app.services.shared.pagination import SQLiteDirection +from invokeai.app.services.users.users_common import UserCreateRequest from tests.fixtures.sqlite_database import sqlite_cursor @@ -106,34 +108,42 @@ def test_admin_board_listing_batches_owner_lookup(mock_invoker: Invoker) -> None mock_invoker.services.users.get_many.assert_called_once() # type: ignore[attr-defined] -def test_non_admin_board_listing_skips_owner_lookup(mock_invoker: Invoker) -> None: - """Non-admin listings don't show owner names, so they must not query for them at all.""" - board_id = mock_invoker.services.board_records.save("Board", "user").board_id - mock_invoker.services.gallery.get_board_media_summaries = MagicMock( # type: ignore[attr-defined] - return_value={ - board_id: SimpleNamespace( - cover_image_name=None, - cover_video_name=None, - image_count=0, - video_count=0, - asset_count=0, - asset_video_count=0, - ) - } - ) - mock_invoker.services.users.get = MagicMock() # type: ignore[method-assign] - mock_invoker.services.users.get_many = MagicMock() # type: ignore[method-assign] +@pytest.mark.parametrize(("is_admin", "nameless_owner"), [(False, "Unknown user"), (True, "nameless@example.com")]) +def test_a_listing_names_each_owner_with_one_lookup(mock_invoker: Invoker, is_admin: bool, nameless_owner: str) -> None: + """Every reader sees whose board it is; only admins see an email for an owner without a display name.""" + users = mock_invoker.services.users + named = users.create(UserCreateRequest(email="named@example.com", display_name="Named", password="TestPass123")) + nameless = users.create(UserCreateRequest(email="nameless@example.com", password="TestPass123")) + reader = users.create(UserCreateRequest(email="reader@example.com", password="TestPass123", is_admin=is_admin)) + for owner in (named, nameless): + board_id = mock_invoker.services.board_records.save(owner.email, owner.user_id).board_id + mock_invoker.services.board_records.update(board_id, BoardChanges(general_access=GeneralAccess.Viewer)) + get_many = MagicMock(side_effect=users.get_many) + users.get_many = get_many # type: ignore[method-assign] result = mock_invoker.services.boards.get_all( - user_id="user", - is_admin=False, + user_id=reader.user_id, + is_admin=is_admin, order_by=BoardRecordOrderBy.Name, direction=SQLiteDirection.Ascending, ) - assert [dto.owner_username for dto in result] == [None] - mock_invoker.services.users.get.assert_not_called() # type: ignore[attr-defined] - mock_invoker.services.users.get_many.assert_not_called() # type: ignore[attr-defined] + assert {dto.board_name: dto.owner_username for dto in result} == { + "named@example.com": "Named", + "nameless@example.com": nameless_owner, + } + assert get_many.call_count == 1 + + +def test_an_owner_without_a_display_name_sees_their_own_email(mock_invoker: Invoker) -> None: + owner = mock_invoker.services.users.create(UserCreateRequest(email="me@example.com", password="TestPass123")) + mock_invoker.services.board_records.save("Mine", owner.user_id) + + result = mock_invoker.services.boards.get_all( + user_id=owner.user_id, is_admin=False, order_by=BoardRecordOrderBy.Name, direction=SQLiteDirection.Ascending + ) + + assert [dto.owner_username for dto in result] == ["me@example.com"] def test_a_board_dto_counts_each_kind_of_media(mock_invoker: Invoker, mock_sqlite_database: Database) -> None: @@ -153,7 +163,7 @@ def test_a_board_dto_counts_each_kind_of_media(mock_invoker: Invoker, mock_sqlit for name in ("first.mp4", "second.mp4", "asset.mp4"): services.board_video_records.add_video_to_board(board_id, name) - dto = services.boards.get_dto(board_id) + dto = services.boards.get_dto(board_id, viewer=BoardViewer(user_id="system", is_admin=True)) assert (dto.image_count, dto.asset_count, dto.video_count, dto.asset_video_count) == (1, 2, 3, 1) diff --git a/tests/app/services/shared/test_invocation_context_boards.py b/tests/app/services/shared/test_invocation_context_boards.py index ab42e736a04..bd0894183ff 100644 --- a/tests/app/services/shared/test_invocation_context_boards.py +++ b/tests/app/services/shared/test_invocation_context_boards.py @@ -2,6 +2,13 @@ import pytest +from invokeai.app.services.board_access.board_access_common import ( + BoardAccessDeniedError, + BoardAction, + BoardViewer, + ItemAccessDeniedError, + ItemAction, +) from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy from invokeai.app.services.boards.boards_base import BoardServiceABC from invokeai.app.services.shared.invocation_context import BoardsInterface @@ -57,3 +64,55 @@ def test_get_all_rejects_deactivated_queue_user_in_multiuser_mode(is_admin: bool boards.get_all() services.boards.get_all.assert_not_called() + + +ACTIVE_USER = MagicMock(is_admin=False, is_active=True) +QUEUE_USER = BoardViewer(user_id="queue-user", is_admin=False) + + +def test_a_board_a_node_reads_is_checked_and_described_to_the_queue_user() -> None: + boards, services = _make_interface(multiuser=True, user=ACTIVE_USER) + + boards.get_dto("board") + + services.board_access.require.assert_called_once_with("board", "queue-user", False, BoardAction.Read) + services.boards.get_dto.assert_called_once_with("board", viewer=QUEUE_USER) + + +@pytest.mark.parametrize("read", ["get_dto", "get_all_image_names_for_board"]) +def test_a_node_cannot_read_a_board_its_user_cannot(read: str) -> None: + boards, services = _make_interface(multiuser=True, user=ACTIVE_USER) + services.board_access.require.side_effect = BoardAccessDeniedError("board", BoardAction.Read, readable=False) + + with pytest.raises(PermissionError, match="not authorized"): + getattr(boards, read)("board") + + services.boards.get_dto.assert_not_called() + services.board_images.get_all_board_image_names_for_board.assert_not_called() + + +def test_a_node_adds_only_its_users_own_images_to_boards_they_can_add_to() -> None: + boards, services = _make_interface(multiuser=True, user=ACTIVE_USER) + + boards.add_image_to_board("board", "image.png") + services.board_access.require.assert_called_once_with("board", "queue-user", False, BoardAction.AddMedia) + services.board_access.require_image.assert_called_once_with("image.png", "queue-user", False, ItemAction.Edit) + services.board_images.add_image_to_board.assert_called_once_with("board", "image.png") + + services.board_images.add_image_to_board.reset_mock() + services.board_access.require_image.side_effect = ItemAccessDeniedError("image.png", ItemAction.Edit) + with pytest.raises(PermissionError, match="not authorized"): + boards.add_image_to_board("board", "image.png") + services.board_images.add_image_to_board.assert_not_called() + + +def test_a_deactivated_queue_user_cannot_create_or_read_boards() -> None: + boards, services = _make_interface(multiuser=True, user=MagicMock(is_admin=True, is_active=False)) + + with pytest.raises(PermissionError): + boards.create("New") + with pytest.raises(PermissionError): + boards.get_dto("board") + + services.boards.create.assert_not_called() + services.boards.get_dto.assert_not_called() diff --git a/tests/app/services/shared/test_invocation_context_images.py b/tests/app/services/shared/test_invocation_context_images.py index fd02c883763..be822902da6 100644 --- a/tests/app/services/shared/test_invocation_context_images.py +++ b/tests/app/services/shared/test_invocation_context_images.py @@ -2,15 +2,24 @@ import pytest -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_access.board_access_common import ( + BoardAccessDeniedError, + BoardAction, + ItemAccessDeniedError, + ItemAction, +) from invokeai.app.services.shared.invocation_context import ImagesInterface -def _make_interface(visibility: BoardVisibility, owner_id: str = "owner") -> tuple[ImagesInterface, MagicMock]: +def _make_interface(allowed: bool) -> tuple[ImagesInterface, MagicMock]: + """The board access service decides; these tests check how generation asks it and acts on the answer.""" services = MagicMock() services.configuration.multiuser = True services.users.get.return_value = MagicMock(is_admin=False) - services.boards.get_dto.return_value = MagicMock(user_id=owner_id, board_visibility=visibility) + if not allowed: + services.board_access.require.side_effect = BoardAccessDeniedError("board", BoardAction.AddMedia, True) + services.board_access.require_image.side_effect = ItemAccessDeniedError("item", ItemAction.Read) + services.board_access.require_video.side_effect = ItemAccessDeniedError("item", ItemAction.Read) data = MagicMock() data.queue_item.user_id = "queue-user" data.queue_item.workflow = None @@ -21,8 +30,8 @@ def _make_interface(visibility: BoardVisibility, owner_id: str = "owner") -> tup return ImagesInterface(services, data, MagicMock()), services -def test_image_save_rejects_foreign_private_board() -> None: - images, services = _make_interface(BoardVisibility.Private) +def test_image_save_rejects_a_board_the_user_cannot_add_to() -> None: + images, services = _make_interface(allowed=False) with pytest.raises(PermissionError, match="not authorized"): images.save(MagicMock(), board_id="foreign-board") @@ -30,24 +39,17 @@ def test_image_save_rejects_foreign_private_board() -> None: services.images.create.assert_not_called() -def test_image_save_allows_foreign_public_board() -> None: - images, services = _make_interface(BoardVisibility.Public) +def test_image_save_allows_a_board_the_user_can_add_to() -> None: + images, services = _make_interface(allowed=True) images.save(MagicMock(), board_id="public-board") services.images.create.assert_called_once() - - -def test_image_save_allows_board_owner() -> None: - images, services = _make_interface(BoardVisibility.Private, owner_id="queue-user") - - images.save(MagicMock(), board_id="owned-board") - - services.images.create.assert_called_once() + services.board_access.require.assert_called_once_with("public-board", "queue-user", False, BoardAction.AddMedia) def test_image_save_allows_any_board_in_single_user_mode() -> None: - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.configuration.multiuser = False services.users.get.return_value = None @@ -66,7 +68,7 @@ def test_image_save_allows_any_board_in_single_user_mode() -> None: ], ) def test_image_reads_reject_foreign_private_image(method_name: str, service_method: str) -> None: - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.image_records.get_user_id.return_value = "owner" services.board_image_records.get_board_for_image.return_value = "foreign-board" @@ -77,17 +79,18 @@ def test_image_reads_reject_foreign_private_image(method_name: str, service_meth def test_image_dto_allows_foreign_shared_image() -> None: - images, services = _make_interface(BoardVisibility.Shared) + images, services = _make_interface(allowed=True) services.image_records.get_user_id.return_value = "owner" services.board_image_records.get_board_for_image.return_value = "shared-board" images.get_dto("shared-image") services.images.get_dto.assert_called_once_with("shared-image") + services.board_access.require_image.assert_called_once_with("shared-image", "queue-user", False, ItemAction.Read) def test_image_read_allows_any_image_in_single_user_mode() -> None: - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.configuration.multiuser = False services.users.get.return_value = None @@ -98,7 +101,7 @@ def test_image_read_allows_any_image_in_single_user_mode() -> None: def test_image_read_rejects_deactivated_queue_user() -> None: """A deactivated account keeps no queue-time privileges, even for its own images.""" - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=False) services.image_records.get_user_id.return_value = "queue-user" @@ -109,7 +112,7 @@ def test_image_read_rejects_deactivated_queue_user() -> None: def test_image_read_rejects_deleted_queue_user() -> None: - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.users.get.return_value = None with pytest.raises(PermissionError, match="not authorized"): @@ -120,7 +123,7 @@ def test_image_read_rejects_deleted_queue_user() -> None: def test_image_save_rejects_deactivated_queue_user() -> None: """No output may be saved on behalf of a deactivated account — even uncategorized.""" - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=False) with pytest.raises(PermissionError, match="not authorized"): @@ -130,7 +133,7 @@ def test_image_save_rejects_deactivated_queue_user() -> None: def test_image_save_allows_active_queue_user_without_board() -> None: - images, services = _make_interface(BoardVisibility.Private) + images, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=True) images.save(MagicMock()) @@ -139,7 +142,7 @@ def test_image_save_allows_active_queue_user_without_board() -> None: def test_images_save_attributes_the_output_to_the_queue_items_project() -> None: - images, services = _make_interface(BoardVisibility.Private, owner_id="queue-user") + images, services = _make_interface(allowed=True) images.save(MagicMock()) diff --git a/tests/app/services/shared/test_invocation_context_videos.py b/tests/app/services/shared/test_invocation_context_videos.py index 01c2e525608..acaf1eb7d1f 100644 --- a/tests/app/services/shared/test_invocation_context_videos.py +++ b/tests/app/services/shared/test_invocation_context_videos.py @@ -3,14 +3,23 @@ import pytest -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_access.board_access_common import ( + BoardAccessDeniedError, + BoardAction, + ItemAccessDeniedError, + ItemAction, +) from invokeai.app.services.shared.invocation_context import VideosInterface -def _make_interface(visibility: BoardVisibility, owner_id: str = "owner") -> tuple[VideosInterface, MagicMock]: +def _make_interface(allowed: bool) -> tuple[VideosInterface, MagicMock]: + """The board access service decides; these tests check how generation asks it and acts on the answer.""" services = MagicMock() services.users.get.return_value = MagicMock(is_admin=False) - services.boards.get_dto.return_value = MagicMock(user_id=owner_id, board_visibility=visibility) + if not allowed: + services.board_access.require.side_effect = BoardAccessDeniedError("board", BoardAction.AddMedia, True) + services.board_access.require_image.side_effect = ItemAccessDeniedError("item", ItemAction.Read) + services.board_access.require_video.side_effect = ItemAccessDeniedError("item", ItemAction.Read) data = MagicMock() data.queue_item.user_id = "queue-user" data.queue_item.workflow = None @@ -21,8 +30,8 @@ def _make_interface(visibility: BoardVisibility, owner_id: str = "owner") -> tup return VideosInterface(services, data, MagicMock()), services -def test_video_save_rejects_foreign_private_board() -> None: - videos, services = _make_interface(BoardVisibility.Private) +def test_video_save_rejects_a_board_the_user_cannot_add_to() -> None: + videos, services = _make_interface(allowed=False) with pytest.raises(PermissionError, match="not authorized"): videos.save(Path("output.mp4"), width=64, height=64, duration=1.0, board_id="foreign-board") @@ -30,25 +39,17 @@ def test_video_save_rejects_foreign_private_board() -> None: services.videos.create.assert_not_called() -@pytest.mark.parametrize("visibility", [BoardVisibility.Public]) -def test_video_save_allows_writable_foreign_board(visibility: BoardVisibility) -> None: - videos, services = _make_interface(visibility) +def test_video_save_allows_a_board_the_user_can_add_to() -> None: + videos, services = _make_interface(allowed=True) videos.save(Path("output.mp4"), width=64, height=64, duration=1.0, board_id="public-board") services.videos.create.assert_called_once() - - -def test_video_save_allows_board_owner() -> None: - videos, services = _make_interface(BoardVisibility.Private, owner_id="queue-user") - - videos.save(Path("output.mp4"), width=64, height=64, duration=1.0, board_id="owned-board") - - services.videos.create.assert_called_once() + services.board_access.require.assert_called_once_with("public-board", "queue-user", False, BoardAction.AddMedia) def test_video_path_rejects_foreign_private_video() -> None: - videos, services = _make_interface(BoardVisibility.Private) + videos, services = _make_interface(allowed=False) services.video_records.get_user_id.return_value = "owner" services.board_video_records.get_board_for_video.return_value = "foreign-board" @@ -59,18 +60,20 @@ def test_video_path_rejects_foreign_private_video() -> None: def test_video_dto_allows_foreign_shared_video() -> None: - videos, services = _make_interface(BoardVisibility.Shared) + videos, services = _make_interface(allowed=True) services.video_records.get_user_id.return_value = "owner" services.board_video_records.get_board_for_video.return_value = "shared-board" videos.get_dto("shared.mp4") services.videos.get_dto.assert_called_once_with("shared.mp4") + services.board_access.require_video.assert_called_once_with("shared.mp4", "queue-user", False, ItemAction.Read) + services.board_access.require_image.assert_not_called() def test_video_read_rejects_deactivated_queue_user() -> None: """A deactivated account keeps no queue-time privileges, even for its own videos.""" - videos, services = _make_interface(BoardVisibility.Private) + videos, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=False) services.video_records.get_user_id.return_value = "queue-user" @@ -82,7 +85,7 @@ def test_video_read_rejects_deactivated_queue_user() -> None: def test_video_save_rejects_deactivated_queue_user() -> None: """No output may be saved on behalf of a deactivated account — even uncategorized.""" - videos, services = _make_interface(BoardVisibility.Private) + videos, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=False) with pytest.raises(PermissionError, match="not authorized"): @@ -92,7 +95,7 @@ def test_video_save_rejects_deactivated_queue_user() -> None: def test_video_save_allows_active_queue_user_without_board() -> None: - videos, services = _make_interface(BoardVisibility.Private) + videos, services = _make_interface(allowed=False) services.users.get.return_value = MagicMock(is_admin=False, is_active=True) videos.save(Path("output.mp4"), width=64, height=64, duration=1.0) @@ -101,7 +104,7 @@ def test_video_save_allows_active_queue_user_without_board() -> None: def test_videos_save_attributes_the_output_to_the_queue_items_project() -> None: - videos, services = _make_interface(BoardVisibility.Private, owner_id="queue-user") + videos, services = _make_interface(allowed=True) videos.save(Path("output.mp4"), width=64, height=64, duration=1.0) diff --git a/tests/app/services/test_workflow_call_batch.py b/tests/app/services/test_workflow_call_batch.py index 9d623b11db4..7067c1f296d 100644 --- a/tests/app/services/test_workflow_call_batch.py +++ b/tests/app/services/test_workflow_call_batch.py @@ -1,7 +1,9 @@ +from types import SimpleNamespace from typing import Any import pytest +from invokeai.app.services.board_access.board_access_common import BoardAccessDeniedError, BoardAction from invokeai.app.services.session_processor.workflow_call_batch import ( build_child_workflow_session_results, build_child_workflow_sessions, @@ -112,6 +114,33 @@ def _call_frame() -> WorkflowCallFrame: ) +def test_a_deactivated_callers_expansion_reads_no_board() -> None: + from invokeai.app.services.session_processor.workflow_call_batch import ( + UnsupportedWorkflowNodeError, + _assert_user_can_access_board, + ) + + services = SimpleNamespace( + users=SimpleNamespace(get=lambda user_id: SimpleNamespace(is_admin=True, is_active=False)), + board_access=_board_access(allows_read=True), + ) + + with pytest.raises(UnsupportedWorkflowNodeError, match="does not have access"): + _assert_user_can_access_board("board", services, "caller-1") + + +def _board_access(allows_read: bool) -> Any: + """The board access service, answering every read as given: the expansion's part is asking it.""" + + def require(board_id: str, user_id: str, is_admin: bool, action: BoardAction) -> None: + # The callers in these tests are ordinary accounts; an expansion must ask as one, for a read. + assert (is_admin, action) == (False, BoardAction.Read) + if not allows_read: + raise BoardAccessDeniedError(board_id, action, readable=False) + + return SimpleNamespace(require=require) + + def test_build_child_workflow_sessions_expands_direct_integer_batch() -> None: workflow = _workflow_dump( nodes=[ @@ -305,19 +334,7 @@ def test_build_child_workflow_sessions_rejects_inaccessible_image_generator_boar ) }, )(), - "board_records": type( - "BoardRecords", - (), - { - "get": staticmethod( - lambda board_id: type( - "BoardRecord", - (), - {"board_id": board_id, "user_id": "owner-1", "board_visibility": "private"}, - )() - ) - }, - )(), + "board_access": _board_access(allows_read=False), "users": type( "Users", (), @@ -403,19 +420,7 @@ def test_build_child_workflow_sessions_allows_shared_image_generator_board() -> ) }, )(), - "board_records": type( - "BoardRecords", - (), - { - "get": staticmethod( - lambda board_id: type( - "BoardRecord", - (), - {"board_id": board_id, "user_id": "owner-1", "board_visibility": "shared"}, - )() - ) - }, - )(), + "board_access": _board_access(allows_read=True), "users": type( "Users", (), @@ -1402,7 +1407,8 @@ def test_build_child_workflow_sessions_supports_image_generator_from_board() -> lambda board_id, categories, is_intermediate: ["img-a", "img-b"] ) }, - )() + )(), + "board_access": _board_access(allows_read=True), }, )() diff --git a/tests/app/services/workflow_call_test_utils.py b/tests/app/services/workflow_call_test_utils.py index 490d7bb2187..9222442ad83 100644 --- a/tests/app/services/workflow_call_test_utils.py +++ b/tests/app/services/workflow_call_test_utils.py @@ -816,6 +816,13 @@ def get(self, user_id: str): return None +class _DummyBoardAccess: + """Every board readable: these tests run generators, not authorization.""" + + def require(self, board_id: str, user_id: str, is_admin: bool, action: object) -> None: + return None + + class _DummyBoardImages: def get_all_board_image_names_for_board(self, board_id: str, categories, is_intermediate: bool | None): if board_id == "board-1": @@ -896,6 +903,7 @@ def _build_workflow_runner(monkeypatch: pytest.MonkeyPatch, session_queue=None): "invocation_cache": MemoryInvocationCache(max_cache_size=0), "workflow_records": workflow_records, "users": _DummyUsers(), + "board_access": _DummyBoardAccess(), "board_images": _DummyBoardImages(), "images": _DummyImages(), "session_queue": session_queue or _DummySessionQueue(), diff --git a/tests/app/test_media_upload_socketio.py b/tests/app/test_media_upload_socketio.py index 4498b419277..d4312119887 100644 --- a/tests/app/test_media_upload_socketio.py +++ b/tests/app/test_media_upload_socketio.py @@ -1,11 +1,12 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock +from typing import Optional +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from invokeai.app.api.sockets import SocketIO -from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.board_access.board_access_common import BoardAudience from invokeai.app.services.events.events_common import ImageUploadedEvent, MediaUploadedEventBase, VideoUploadedEvent from invokeai.app.services.image_records.image_records_common import ImageCategory @@ -15,36 +16,36 @@ def anyio_backend() -> str: return "asyncio" -def _patch_multiuser(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: - invoker = SimpleNamespace(services=SimpleNamespace(configuration=SimpleNamespace(multiuser=enabled))) +def _patch_services(monkeypatch: pytest.MonkeyPatch, multiuser: bool, audience: Optional[BoardAudience]) -> MagicMock: + """The routing reads the multiuser setting and asks the board access service who sees the board.""" + board_access = MagicMock() + board_access.audience.return_value = audience + invoker = SimpleNamespace( + services=SimpleNamespace(configuration=SimpleNamespace(multiuser=multiuser), board_access=board_access) + ) monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker)) + return board_access -def _image_event( - board_visibility: BoardVisibility | None, - board_id: str | None = "board-1", - board_owner_id: str | None = "user-1", - shared_user_ids: list[str] | None = None, -) -> ImageUploadedEvent: +def _image_event(board_id: Optional[str] = "board-1") -> ImageUploadedEvent: return ImageUploadedEvent.build( - image_name="upload.png", - image_category=ImageCategory.USER, - user_id="user-1", - board_id=board_id, - board_owner_id=board_owner_id if board_id else None, - board_visibility=board_visibility, - shared_user_ids=shared_user_ids or [], + image_name="upload.png", image_category=ImageCategory.USER, user_id="user-1", board_id=board_id ) -async def _route(monkeypatch: pytest.MonkeyPatch, event: MediaUploadedEventBase, multiuser: bool) -> list: +async def _route( + monkeypatch: pytest.MonkeyPatch, + event: MediaUploadedEventBase, + multiuser: bool = True, + audience: Optional[BoardAudience] = None, +) -> tuple[list, MagicMock]: socketio = SocketIO(FastAPI()) socketio._sio.emit = AsyncMock() - _patch_multiuser(monkeypatch, multiuser) + board_access = _patch_services(monkeypatch, multiuser, audience) await socketio._handle_media_event((event.__event_name__, event)) - return [call.kwargs for call in socketio._sio.emit.await_args_list] + return [call.kwargs for call in socketio._sio.emit.await_args_list], board_access def test_upload_events_are_registered_with_the_socket_handler() -> None: @@ -62,73 +63,71 @@ def test_upload_events_are_registered_with_the_socket_handler() -> None: @pytest.mark.anyio async def test_single_user_upload_goes_to_the_admin_room(monkeypatch: pytest.MonkeyPatch) -> None: - emits = await _route(monkeypatch, _image_event(BoardVisibility.Private), multiuser=False) + emits, _ = await _route(monkeypatch, _image_event(), multiuser=False) assert [emit["room"] for emit in emits] == ["admin"] assert emits[0]["event"] == "image_uploaded" assert emits[0]["data"]["image_name"] == "upload.png" -@pytest.mark.parametrize( - ("board_visibility", "board_id"), - [(BoardVisibility.Private, "board-1"), (None, None)], - ids=["private-board", "no-board"], -) @pytest.mark.anyio -async def test_private_upload_reaches_only_the_uploader_and_admins( - monkeypatch: pytest.MonkeyPatch, board_visibility: BoardVisibility | None, board_id: str | None -) -> None: - emits = await _route(monkeypatch, _image_event(board_visibility, board_id), multiuser=True) +async def test_an_upload_to_no_board_reaches_only_the_uploader_and_admins(monkeypatch: pytest.MonkeyPatch) -> None: + emits, board_access = await _route(monkeypatch, _image_event(board_id=None)) - assert len(emits) == 1 - assert emits[0]["room"] == ["user:user-1", "admin"] + assert [emit["room"] for emit in emits] == [["user:user-1", "admin"]] + board_access.audience.assert_not_called() @pytest.mark.anyio -async def test_admin_upload_into_another_users_private_board_reaches_that_owner( - monkeypatch: pytest.MonkeyPatch, -) -> None: - emits = await _route(monkeypatch, _image_event(BoardVisibility.Private, board_owner_id="owner-2"), multiuser=True) +async def test_a_restricted_board_upload_reaches_its_owner_and_members(monkeypatch: pytest.MonkeyPatch) -> None: + """An admin may upload into another account's board; its owner and members must see it arrive.""" + audience = BoardAudience(everyone=False, user_ids=("owner-2", "member-1")) - assert len(emits) == 1 - assert emits[0]["room"] == ["user:user-1", "admin", "user:owner-2"] + emits, board_access = await _route(monkeypatch, _image_event(), audience=audience) + assert [emit["room"] for emit in emits] == [["user:user-1", "admin", "user:owner-2", "user:member-1"]] + board_access.audience.assert_called_once_with("board-1") -@pytest.mark.anyio -async def test_private_board_upload_also_reaches_its_share_recipients(monkeypatch: pytest.MonkeyPatch) -> None: - """A board shared with named users is readable by them, so their galleries must refresh too.""" - event = _image_event(BoardVisibility.Private, shared_user_ids=["viewer-1", "viewer-2"]) - emits = await _route(monkeypatch, event, multiuser=True) +@pytest.mark.anyio +async def test_an_upload_to_a_board_everyone_can_open_is_broadcast(monkeypatch: pytest.MonkeyPatch) -> None: + emits, _ = await _route(monkeypatch, _image_event(), audience=BoardAudience(everyone=True, user_ids=())) assert len(emits) == 1 - assert emits[0]["room"] == ["user:user-1", "admin", "user:viewer-1", "user:viewer-2"] + assert "room" not in emits[0] -@pytest.mark.parametrize("board_visibility", [BoardVisibility.Shared, BoardVisibility.Public]) @pytest.mark.anyio -async def test_upload_to_a_board_everyone_can_view_is_broadcast( - monkeypatch: pytest.MonkeyPatch, board_visibility: BoardVisibility +async def test_an_upload_to_a_board_deleted_meanwhile_reaches_only_the_uploader_and_admins( + monkeypatch: pytest.MonkeyPatch, ) -> None: - emits = await _route(monkeypatch, _image_event(board_visibility), multiuser=True) + emits, _ = await _route(monkeypatch, _image_event(), audience=None) - assert len(emits) == 1 - assert "room" not in emits[0] - assert emits[0]["data"]["board_visibility"] == board_visibility.value + assert [emit["room"] for emit in emits] == [["user:user-1", "admin"]] @pytest.mark.anyio async def test_video_uploads_route_like_image_uploads(monkeypatch: pytest.MonkeyPatch) -> None: event = VideoUploadedEvent.build( - video_name="upload.mp4", - video_category=ImageCategory.USER, - user_id="user-1", - board_id="board-1", - board_owner_id="user-1", - board_visibility=BoardVisibility.Private, - shared_user_ids=[], + video_name="upload.mp4", video_category=ImageCategory.USER, user_id="user-1", board_id="board-1" ) - emits = await _route(monkeypatch, event, multiuser=True) + emits, _ = await _route(monkeypatch, event, audience=BoardAudience(everyone=False, user_ids=("user-1",))) assert [(emit["event"], emit["room"]) for emit in emits] == [("video_uploaded", ["user:user-1", "admin"])] + + +@pytest.mark.anyio +async def test_an_upload_whose_board_cannot_be_looked_up_reaches_only_the_uploader_and_admins( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A storage error must not drop the event, nor announce it to anyone who may not see the board.""" + socketio = SocketIO(FastAPI()) + socketio._sio.emit = AsyncMock() + board_access = _patch_services(monkeypatch, multiuser=True, audience=None) + board_access.audience.side_effect = RuntimeError("database is locked") + + event = _image_event() + await socketio._handle_media_event((event.__event_name__, event)) + + assert [call.kwargs["room"] for call in socketio._sio.emit.await_args_list] == [["user:user-1", "admin"]] From 1b8d746d6060bff67de2d41c52c9aac17cd60ade Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 11:11:26 -0600 Subject: [PATCH 03/16] feat(projects): key projects globally and scope project queue reads to their owner New draft projects mint project- ids like every other client path; a create naming another account's id answers 409 and changes nothing. Origin-scoped queue reads (item ids, current, next, status) return a non-admin only their own items, keeping their query plans. Document global project ids and the queue scoping. --- .../Multi-User Mode/api-guide.mdx | 3 + .../development/Front End/project-files.mdx | 6 +- invokeai/app/api/routers/session_queue.py | 59 ++++++++-- .../project_records_default.py | 8 +- .../session_queue/session_queue_base.py | 28 +++-- .../session_queue/session_queue_default.py | 43 ++++--- .../shared/database/queries/session_queue.py | 64 +++++++---- invokeai/frontend/api/openapi.json | 20 ++-- invokeai/frontend/api/schema.ts | 19 ++-- .../webv2/src/workbench/projects/api.ts | 8 +- .../src/workbench/workbenchState.test.ts | 14 +++ .../webv2/src/workbench/workbenchState.ts | 5 +- tests/app/routers/test_projects.py | 29 +++++ .../test_session_queue_origin_scope.py | 105 ++++++++++++++++++ .../test_session_queue_list_queue_items.py | 8 +- .../test_session_queue_query_plans.py | 37 ++++++ .../session_queue/test_session_queue_reads.py | 6 +- .../test_session_queue_status_user_scoping.py | 3 +- ...6_10_04_add_session_queue_listing_index.py | 4 +- 19 files changed, 375 insertions(+), 94 deletions(-) create mode 100644 tests/app/routers/test_session_queue_origin_scope.py diff --git a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx index 15e866447ee..fbf82301ca8 100644 --- a/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx +++ b/docs/src/content/docs/configuration/Multi-User Mode/api-guide.mdx @@ -551,6 +551,9 @@ GET /api/v1/workflows/ # Regular users see their own queue items in full and may see redacted details for other users' items on queue-status endpoints GET /api/v1/queue/... # Queue data, sanitized for non-admin viewers +# With origin_prefix, item_ids, current, next and status cover only the caller's own items +GET /api/v1/queue/{queue_id}/status?origin_prefix=webv2:p::q: + # Administrators see all queue items in full GET /api/v1/queue/... # Full queue data ``` diff --git a/docs/src/content/docs/development/Front End/project-files.mdx b/docs/src/content/docs/development/Front End/project-files.mdx index 5f9043f4da1..4991fca692e 100644 --- a/docs/src/content/docs/development/Front End/project-files.mdx +++ b/docs/src/content/docs/development/Front End/project-files.mdx @@ -8,9 +8,13 @@ Import also accepts the `.invokeproject.json` envelope shipped by earlier webv2 `.invk` is shared with the previous frontend's [canvas projects](/development/front-end/canvas-projects/), which write version 1 of the same container. Workbench projects write version 2. The two are deliberately not interchangeable — see [Versions](#versions). +## Project ids + +A project id is global: no two accounts hold the same one. Every client path mints `project-` (drafts, imports and duplicates alike; conflict copies derive theirs from the source project's id), and a create naming an id another account already holds is refused with `409`, like a create that already happened. A lookup tells the two apart: the caller's own project answers, another account's answers `404`, as every read or save of a project the caller does not own does (deleting one is a no-op). Queue items record their project in their origin (`webv2:p::q:`), and a non-admin's origin-scoped queue reads return only their own items. + ## Boards belong to projects -A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects` is keyed by `(user_id, project_id)` and adding a composite constraint would mean rebuilding `boards` under enforced cascades. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. A board keeps its general access and memberships when it moves between the Library and a project; only the inbox's access follows its project. +A board lives in exactly one of its owner's projects, or in the **Library** — the tier every project can see. `boards.project_id` is the stored membership (`NULL` is the Library); it is a plain indexed column rather than a foreign key, because `projects.board_id` already references `boards`, and two tables that reference each other have no order to be created or copied in. Deleting an account clears its boards' memberships in the same transaction. The services keep it consistent: `POST /boards/?project_id=` inserts from the project row itself, `PATCH /boards/{id}` with `project_id` (a project, or `null` for the Library) moves a board and guards the destination's existence in the same `UPDATE`, and both answer `404` for a project the owner does not have. A board keeps its general access and memberships when it moves between the Library and a project; only the inbox's access follows its project. Every project also has an **inbox**: the one board `projects.board_id` names (`NOT NULL UNIQUE`, foreign key `ON DELETE RESTRICT`). It is created with the project, is always a member of it, and is the only board the project APIs manage. diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index c64f32623b1..46407dce7ae 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -200,6 +200,15 @@ def _get_queue_item_for_retry(item_id: int) -> SessionQueueItem: AnyQueueItem = TypeVar("AnyQueueItem", SessionQueueItem, SessionQueueItemSummary) +def origin_owner(current_user: CurrentUserOrDefault, origin_prefix: Optional[str]) -> Optional[str]: + """The account an origin-scoped read is limited to: the caller's own, unless they are an admin. + + A project's origin names one account's project, so another account's items under it are not part of it. Matching + the prefix against everyone's items would also let any caller probe, prefix by prefix, for other accounts' project + ids.""" + return None if origin_prefix is None or current_user.is_admin else current_user.user_id + + def sanitize_queue_item_for_user(queue_item: AnyQueueItem, current_user_id: str, is_admin: bool) -> AnyQueueItem: """Sanitize a queue item, or a queue item summary, for a non-admin viewing another user's item. @@ -345,7 +354,11 @@ def get_queue_item_ids( queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), limit: Optional[int] = Query( default=None, @@ -361,7 +374,8 @@ def get_queue_item_ids( returned, and it is not a total (counting every match would read the whole queue history); use the queue status for totals. - IDs for every user's items are returned (item ids carry no sensitive data on their own). + Without `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their + own); with it, a non-admin gets only their own. When the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging to other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see partially-redacted entries for other users' jobs in the queue list, while still revealing @@ -371,7 +385,11 @@ def get_queue_item_ids( """ try: return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( - queue_id=queue_id, order_dir=order_dir, origin_prefix=origin_prefix, limit=limit + queue_id=queue_id, + order_dir=order_dir, + owner_id=origin_owner(current_user, origin_prefix), + origin_prefix=origin_prefix, + limit=limit, ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") @@ -685,12 +703,18 @@ def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> Optional[SessionQueueItem]: """Gets the currently execution queue item""" try: - item = ApiDependencies.invoker.services.session_queue.get_current_for_api(queue_id, origin_prefix=origin_prefix) + item = ApiDependencies.invoker.services.session_queue.get_current_for_api( + queue_id, origin_prefix=origin_prefix, owner_id=origin_owner(current_user, origin_prefix) + ) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -731,12 +755,18 @@ def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> Optional[SessionQueueItem]: """Gets the next queue item, without executing it""" try: - item = ApiDependencies.invoker.services.session_queue.get_next_for_api(queue_id, origin_prefix=origin_prefix) + item = ApiDependencies.invoker.services.session_queue.get_next_for_api( + queue_id, origin_prefix=origin_prefix, owner_id=origin_owner(current_user, origin_prefix) + ) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -755,13 +785,17 @@ def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), origin_prefix: Optional[str] = Query( - default=None, description="Only include queue items whose origin starts with this prefix" + default=None, + description=( + "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own" + " items match" + ), ), ) -> SessionQueueAndProcessorStatus: - """Gets the status of the session queue. Returns global counts; every user additionally gets - their own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI - like the progress bar to the user's own activity). Non-admin users cannot see the current - item's identifiers unless they own it.""" + """Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when + `origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI + can show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin + users cannot see the current item's identifiers unless they own it.""" try: queue = ApiDependencies.invoker.services.session_queue.get_queue_status( queue_id, @@ -769,6 +803,7 @@ def get_queue_status( acting_user_id=current_user.user_id, origin_prefix=origin_prefix, is_admin=current_user.is_admin, + owner_id=origin_owner(current_user, origin_prefix), ) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) diff --git a/invokeai/app/services/project_records/project_records_default.py b/invokeai/app/services/project_records/project_records_default.py index efd25edb7e2..46c7b99e985 100644 --- a/invokeai/app/services/project_records/project_records_default.py +++ b/invokeai/app/services/project_records/project_records_default.py @@ -158,10 +158,10 @@ def insert(q: Queries) -> Optional[ProjectSummaryDTO]: minimum_canvas_schema_version=minimum_canvas_schema_version, ) except UniqueViolation as error: - # The account has a project with this id, created meanwhile or, when the create brings no board, - # before. It cannot be the board: a new board is the project's alone, and a claimed one is locked and - # claimed by no other project. The board insert or rename rolls back with the project insert, so a - # refused create changes no board. + # A project has this id: the account's own, created meanwhile or, when the create brings no board, + # before; or another account's, since ids are global. It cannot be the board: a new board is the + # project's alone, and a claimed one is locked and claimed by no other project. The board insert or + # rename rolls back with the project insert, so a refused create changes no board. raise ProjectRecordExistsError(new_project_id) from error # Indexed in the document's own transaction: a reader never sees a saved project whose media the # intermediates cleanup does not know about. diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 20aa4b278aa..563d9abc4e8 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -74,18 +74,26 @@ def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Opt """Gets the currently-executing session queue item""" pass - def get_current_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - """Gets the current item in the response projection used by API reads.""" - return self.get_current(queue_id=queue_id, origin_prefix=origin_prefix) + @abstractmethod + def get_current_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + """Gets the current item in the response projection used by API reads; with owner_id, of that user's items + only.""" + pass @abstractmethod def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the next session queue item (does not dequeue it)""" pass - def get_next_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - """Gets the next item in the response projection used by API reads.""" - return self.get_next(queue_id=queue_id, origin_prefix=origin_prefix) + @abstractmethod + def get_next_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + """Gets the next item in the response projection used by API reads; with owner_id, of that user's items + only.""" + pass @abstractmethod def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult: @@ -115,10 +123,12 @@ def get_queue_status( acting_user_id: Optional[str] = None, origin_prefix: Optional[str] = None, is_admin: bool = False, + owner_id: Optional[str] = None, ) -> SessionQueueStatus: """Gets the status of the queue. - Aggregate counts (pending/in_progress/.../total) are always global across all users. + owner_id limits everything below, counts and current item alike, to that user's items; it is a filter, + unlike user_id. Without it, aggregate counts (pending/in_progress/.../total) are global across all users. If user_id is provided, the requesting user's own counts are additionally returned in the user_pending/user_in_progress fields (left None otherwise). Admin callers should also pass their user_id so personal UI (e.g. the progress bar) can distinguish their @@ -267,13 +277,13 @@ def get_queue_item_ids( self, queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, - user_id: Optional[str] = None, + owner_id: Optional[str] = None, origin_prefix: Optional[str] = None, limit: Optional[int] = None, ) -> ItemIdsResult: """Gets the ids of queue items that match the given parameters, in the requested created_at order. - If user_id is provided, only returns items for that user. If limit is provided, returns only the first + If owner_id is provided, only returns items for that user. If limit is provided, returns only the first `limit` ids of that order. total_count is the number of ids in item_ids, which equals the number of matching items only when no limit is given.""" pass diff --git a/invokeai/app/services/session_queue/session_queue_default.py b/invokeai/app/services/session_queue/session_queue_default.py index 65c3b6fa006..09a8ae1ce97 100644 --- a/invokeai/app/services/session_queue/session_queue_default.py +++ b/invokeai/app/services/session_queue/session_queue_default.py @@ -426,16 +426,24 @@ def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Opt return self._hydrate_queue_item(raw_queue_item, quarantine=False)[0] if raw_queue_item is not None else None def _get_queue_item_by_status_for_api( - self, queue_id: str, status: Literal["pending", "in_progress"], origin_prefix: Optional[str] + self, + queue_id: str, + status: Literal["pending", "in_progress"], + origin_prefix: Optional[str], + owner_id: Optional[str], ) -> Optional[SessionQueueItem]: - raw_queue_item = self._queries.session_queue.item_by_status(queue_id, status, origin_prefix) + raw_queue_item = self._queries.session_queue.item_by_status(queue_id, status, origin_prefix, owner_id) return self._project_queue_item_for_read(raw_queue_item) if raw_queue_item is not None else None - def get_current_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - return self._get_queue_item_by_status_for_api(queue_id, "in_progress", origin_prefix) + def get_current_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + return self._get_queue_item_by_status_for_api(queue_id, "in_progress", origin_prefix, owner_id) - def get_next_for_api(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: - return self._get_queue_item_by_status_for_api(queue_id, "pending", origin_prefix) + def get_next_for_api( + self, queue_id: str, origin_prefix: Optional[str] = None, owner_id: Optional[str] = None + ) -> Optional[SessionQueueItem]: + return self._get_queue_item_by_status_for_api(queue_id, "pending", origin_prefix, owner_id) def _set_queue_item_status( self, @@ -955,14 +963,14 @@ def get_queue_item_ids( self, queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, - user_id: Optional[str] = None, + owner_id: Optional[str] = None, origin_prefix: Optional[str] = None, limit: Optional[int] = None, ) -> ItemIdsResult: item_ids = self._queries.session_queue.item_ids( queue_id, descending=order_dir == SQLiteDirection.Descending, - user_id=user_id, + owner_id=owner_id, origin_prefix=origin_prefix, limit=limit, ) @@ -987,13 +995,16 @@ def get_queue_status( acting_user_id: Optional[str] = None, origin_prefix: Optional[str] = None, is_admin: bool = False, + owner_id: Optional[str] = None, ) -> SessionQueueStatus: - # Aggregate counts are global across all users within the requested scope. With user_id, the - # same snapshot also yields that user's own counts, for the per-user portion of the badge; - # they are returned in separate fields and never replace the global counts. Only the current + # Aggregate counts cover every user's items within the requested scope (only owner_id's, if + # given). With user_id, the same snapshot also yields that user's own counts, for the per-user + # portion of the badge; they are returned in separate fields and never replace the aggregate. Only the current # item's identifiers are read, not a full SessionQueueItem: this runs on every status poll and # every status change, and hydrating the item would deserialize its whole session graph. - counts, own_counts, current_item = self._queries.session_queue.status_counts(queue_id, user_id, origin_prefix) + counts, own_counts, current_item = self._queries.session_queue.status_counts( + queue_id, user_id, origin_prefix, owner_id + ) user_pending: Optional[int] = None user_in_progress: Optional[int] = None @@ -1003,17 +1014,19 @@ def get_queue_status( # Redaction is decided from the same current_item snapshot used to embed identifiers, # so a concurrent transition (e.g. B finishing while A's status changes) cannot leave - # stale identifiers in the result. The aggregate counts stay global; only the current + # stale identifiers in the result. The aggregate counts are not redacted; only the current # item's identifiers are gated. acting_user_id (event path) takes precedence over # user_id (API path) when deciding the redaction owner; either being None means a # global caller who may see the current item. is_admin disables redaction outright so # admin callers can pass their user_id (for the per-user counts) without losing # visibility of other users' current item. - owner_user_id = user_id if acting_user_id is None else acting_user_id + redaction_subject = user_id if acting_user_id is None else acting_user_id current_item_id = None current_session_id = None current_batch_id = None - if current_item is not None and (is_admin or owner_user_id is None or current_item.user_id == owner_user_id): + if current_item is not None and ( + is_admin or redaction_subject is None or current_item.user_id == redaction_subject + ): current_item_id = current_item.item_id current_session_id = current_item.session_id current_batch_id = current_item.batch_id diff --git a/invokeai/app/services/shared/database/queries/session_queue.py b/invokeai/app/services/shared/database/queries/session_queue.py index f543bf63eb5..5d37f096d60 100644 --- a/invokeai/app/services/shared/database/queries/session_queue.py +++ b/invokeai/app/services/shared/database/queries/session_queue.py @@ -111,11 +111,13 @@ def _origin_pattern(origin_prefix: Optional[str]) -> Optional[str]: @functools.cache -def _item_by_status(status: str, by_origin: bool) -> Select[Any]: +def _item_by_status(status: str, by_origin: bool, by_owner: bool) -> Select[Any]: """The pending item to run next, or an item in progress (the earliest enqueued of several).""" conditions = [_IN_QUEUE_UNINDEXED, _Q.status == literal(status)] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) ordering = [_Q.priority.desc(), _Q.created_at, _Q.item_id] if status == "pending" else [_Q.item_id] return select(_Q.item_id).where(*conditions).order_by(*ordering).limit(fixed_limit(1)) @@ -156,12 +158,13 @@ def _page(by_status: bool, by_destination: bool, after: bool) -> Select[Any]: @functools.cache -def _item_ids(descending: bool, by_user: bool, by_origin: bool, limited: bool) -> Select[Any]: +def _item_ids(descending: bool, by_owner: bool, by_origin: bool, limited: bool) -> Select[Any]: """The ids, in an order the listing index yields on SQLite without a sort (newest first), or sorting only the items that share a creation time (oldest first); so a limited read stops after its window.""" conditions = [_IN_QUEUE] - if by_user: - conditions.append(_Q.user_id == bindparam("user_id")) + if by_owner: + # Off the account's own index, which holds no creation time: the listing index reads it in order. + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) if by_origin: conditions.append(_origin_matches()) # The item id breaks ties, in enqueue order either way, as SQLite returned them before: a batch's items share @@ -172,22 +175,26 @@ def _item_ids(descending: bool, by_user: bool, by_origin: bool, limited: bool) - @functools.cache -def _status_counts(with_own: bool, by_origin: bool) -> Select[Any]: - """Every status's count in the queue and, `with_own`, in the account's part of it, in one pass: on SQLite the - listing index holds every column read, so no row is.""" +def _status_counts(with_own: bool, by_origin: bool, by_owner: bool) -> Select[Any]: + """Every status's count in the queue (or one account's part of it, `by_owner`) and, `with_own`, in the caller's + part of it, in one pass: on SQLite the listing index holds every column read, so no row is.""" conditions = [_IN_QUEUE] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) own = func.sum(case((_Q.user_id == bindparam("user_id"), 1), else_=0)) if with_own else literal(0) return select(_Q.status, func.count(), own).where(*conditions).group_by(_Q.status) @functools.cache -def _current(by_origin: bool) -> Select[Any]: +def _current(by_origin: bool, by_owner: bool) -> Select[Any]: """The identifiers of the item in progress, the earliest enqueued of several.""" conditions = [_IN_QUEUE_UNINDEXED, _IN_PROGRESS] if by_origin: conditions.append(_origin_matches()) + if by_owner: + conditions.append(Unindexed(_Q.user_id) == bindparam("owner_id")) return ( select(_Q.item_id, _Q.session_id, _Q.batch_id, _Q.user_id) .where(*conditions) @@ -712,12 +719,17 @@ def item(self, conn: Connection, item_id: int) -> Optional[Row[Any]]: @mapped(_item_or_none) @read def item_by_status( - self, conn: Connection, queue_id: str, status: str, origin_prefix: Optional[str] + self, + conn: Connection, + queue_id: str, + status: str, + origin_prefix: Optional[str], + owner_id: Optional[str] = None, ) -> Optional[Row[Any]]: """The `pending` item that runs next (highest priority, then oldest), or the `in_progress` one first - claimed.""" - statement = _item_by_status(status, origin_prefix is not None) - parameters = {"queue_id": queue_id, "origin_pattern": _origin_pattern(origin_prefix)} + claimed; of the account's items only, with `owner_id`.""" + statement = _item_by_status(status, origin_prefix is not None, owner_id is not None) + parameters = {"queue_id": queue_id, "origin_pattern": _origin_pattern(origin_prefix), "owner_id": owner_id} return _row_of(conn, conn.execute(statement, parameters).scalar()) @read @@ -769,16 +781,16 @@ def item_ids( queue_id: str, *, descending: bool, - user_id: Optional[str], + owner_id: Optional[str], origin_prefix: Optional[str], limit: Optional[int] = None, ) -> list[int]: """The ids of the queue's items (the account's, with the origin, if given) by creation time; with `limit`, the first `limit` of that order.""" - statement = _item_ids(descending, user_id is not None, origin_prefix is not None, limit is not None) + statement = _item_ids(descending, owner_id is not None, origin_prefix is not None, limit is not None) parameters = { "queue_id": queue_id, - "user_id": user_id, + "owner_id": owner_id, "origin_pattern": _origin_pattern(origin_prefix), "limit": limit, } @@ -792,12 +804,24 @@ def summaries(self, conn: Connection, queue_id: str, item_ids: Sequence[int]) -> @read def status_counts( - self, conn: Connection, queue_id: str, user_id: Optional[str], origin_prefix: Optional[str] + self, + conn: Connection, + queue_id: str, + user_id: Optional[str], + origin_prefix: Optional[str], + owner_id: Optional[str] = None, ) -> StatusCounts: - by_origin = origin_prefix is not None - parameters = {"queue_id": queue_id, "user_id": user_id, "origin_pattern": _origin_pattern(origin_prefix)} - rows = conn.execute(_status_counts(user_id is not None, by_origin), parameters).all() - current = conn.execute(_current(by_origin), parameters).first() + """The counts and the item in progress, of the account's items only with `owner_id`; `user_id`'s share of + them besides.""" + by_origin, by_owner = origin_prefix is not None, owner_id is not None + parameters = { + "queue_id": queue_id, + "user_id": user_id, + "origin_pattern": _origin_pattern(origin_prefix), + "owner_id": owner_id, + } + rows = conn.execute(_status_counts(user_id is not None, by_origin, by_owner), parameters).all() + current = conn.execute(_current(by_origin, by_owner), parameters).first() return StatusCounts( counts={str(row[0]): int(row[1]) for row in rows}, own_counts={str(row[0]): int(row[2]) for row in rows if row[2]}, diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index 8039fe4639b..fdcab1bb395 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -12420,7 +12420,7 @@ "get": { "tags": ["queue"], "summary": "Get Queue Item Ids", - "description": "Gets the queue item ids that match the given parameters, ordered by creation time.\n\n`total_count` is the number of ids in `item_ids`. Without `limit`, every matching id is returned, so it\nequals the number of matching items. With `limit`, only the first `limit` ids of the same order are\nreturned, and it is not a total (counting every match would read the whole queue history); use the queue\nstatus for totals.\n\nIDs for every user's items are returned (item ids carry no sensitive data on their own).\nWhen the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging\nto other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see\npartially-redacted entries for other users' jobs in the queue list, while still revealing\nonly timestamps and status for items they do not own.\n\ncurrent_user is required so the endpoint stays behind authentication in multiuser mode.", + "description": "Gets the queue item ids that match the given parameters, ordered by creation time.\n\n`total_count` is the number of ids in `item_ids`. Without `limit`, every matching id is returned, so it\nequals the number of matching items. With `limit`, only the first `limit` ids of the same order are\nreturned, and it is not a total (counting every match would read the whole queue history); use the queue\nstatus for totals.\n\nWithout `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their\nown); with it, a non-admin gets only their own.\nWhen the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging\nto other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see\npartially-redacted entries for other users' jobs in the queue list, while still revealing\nonly timestamps and status for items they do not own.\n\ncurrent_user is required so the endpoint stays behind authentication in multiuser mode.", "operationId": "get_queue_item_ids", "security": [ { @@ -12463,10 +12463,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" }, { "name": "limit", @@ -13225,10 +13225,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { @@ -13357,10 +13357,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { @@ -13405,7 +13405,7 @@ "get": { "tags": ["queue"], "summary": "Get Queue Status", - "description": "Gets the status of the session queue. Returns global counts; every user additionally gets\ntheir own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI\nlike the progress bar to the user's own activity). Non-admin users cannot see the current\nitem's identifiers unless they own it.", + "description": "Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when\n`origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI\ncan show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin\nusers cannot see the current item's identifiers unless they own it.", "operationId": "get_queue_status", "security": [ { @@ -13437,10 +13437,10 @@ "type": "null" } ], - "description": "Only include queue items whose origin starts with this prefix", + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match", "title": "Origin Prefix" }, - "description": "Only include queue items whose origin starts with this prefix" + "description": "Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match" } ], "responses": { diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index 05a045ed960..cf4505f049a 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -3065,7 +3065,8 @@ export type paths = { * returned, and it is not a total (counting every match would read the whole queue history); use the queue * status for totals. * - * IDs for every user's items are returned (item ids carry no sensitive data on their own). + * Without `origin_prefix`, IDs for every user's items are returned (item ids carry no sensitive data on their + * own); with it, a non-admin gets only their own. * When the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging * to other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see * partially-redacted entries for other users' jobs in the queue list, while still revealing @@ -3399,10 +3400,10 @@ export type paths = { }; /** * Get Queue Status - * @description Gets the status of the session queue. Returns global counts; every user additionally gets - * their own pending/in_progress counts (so the UI can show an X/Y badge and scope personal UI - * like the progress bar to the user's own activity). Non-admin users cannot see the current - * item's identifiers unless they own it. + * @description Gets the status of the session queue. Returns global counts (a non-admin's counts are their own when + * `origin_prefix` is given); every user additionally gets their own pending/in_progress counts (so the UI + * can show an X/Y badge and scope personal UI like the progress bar to the user's own activity). Non-admin + * users cannot see the current item's identifiers unless they own it. */ get: operations["get_queue_status"]; put?: never; @@ -58124,7 +58125,7 @@ export interface operations { query?: { /** @description The order of sort */ order_dir?: components["schemas"]["SQLiteDirection"]; - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; /** @description Return at most this many ids, from the start of the requested order. Omit to return every matching id */ limit?: number | null; @@ -58570,7 +58571,7 @@ export interface operations { get_current_queue_item: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; @@ -58637,7 +58638,7 @@ export interface operations { get_next_queue_item: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; @@ -58672,7 +58673,7 @@ export interface operations { get_queue_status: { parameters: { query?: { - /** @description Only include queue items whose origin starts with this prefix */ + /** @description Only include queue items whose origin starts with this prefix. For a non-admin caller, only their own items match */ origin_prefix?: string | null; }; header?: never; diff --git a/invokeai/frontend/webv2/src/workbench/projects/api.ts b/invokeai/frontend/webv2/src/workbench/projects/api.ts index f479c2444e8..ced36632c1f 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/api.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/api.ts @@ -325,7 +325,8 @@ export class ProjectCreateAbsentError extends Error { /** * Settle ambiguous creates with a second POST: SQLite serializes writers, while GET 404 can race the initial - * commit. A 201 succeeds; after 409, GET distinguishes an existing project from a board conflict. Other + * commit. A 201 succeeds; after 409, GET distinguishes an existing project from a board conflict or an id another + * account holds. Other * deterministic rejections prove absence. Transport failure remains unknown and must never authorize deletion. */ const settleProjectCreate = async (request: ProjectCreateRequest, owner: AccountScope): Promise => { @@ -333,7 +334,10 @@ const settleProjectCreate = async (request: ProjectCreateRequest, owner: Account const body = serializeCreateProjectRequest(request); const writeBudget = createProjectWriteRetryBudget(); - /** The settled 409 makes this lookup decisive: an existing project committed; 404 means a board conflict. */ + /** + * The settled 409 makes this lookup decisive: an existing project committed; 404 means a board conflict, or an id + * another account holds. + */ const settleConflict = async (conflict: unknown): Promise => { if (projectId === undefined) { throw conflict; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index c9db763eea2..3ceb9692fcc 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -1291,6 +1291,20 @@ describe('adopting a project from another realm', () => { }); }); +describe('new projects', () => { + it('take a global UUID id, since another account may hold any other', () => { + const initial = createInitialWorkbenchState(); + const first = workbenchReducer(initial, { type: 'createProject' }); + const second = workbenchReducer(first, { type: 'createProject' }); + const ids = second.projects.slice(-2).map((project) => project.id); + + for (const id of ids) { + expect(id).toMatch(/^project-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + } + expect(new Set(ids).size).toBe(2); + }); +}); + describe('workbench widget alignment', () => { it('moves a bottom widget between the strip clusters and back', () => { const initial = createInitialWorkbenchState(); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 2e860c44ae9..0c0bcfcf81a 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -88,6 +88,7 @@ import { planSeedSubmission } from '@platform/core/seed'; import { describeError } from '@platform/logging/normalize'; import { WIDGET_REGIONS } from '@workbench/layoutContracts'; import { prependProjectEvent, PROJECT_EVENT_LIMIT } from '@workbench/projectEvents'; +import { createProjectId } from '@workbench/projects/ids'; import type { WorkbenchQueueItem as QueueItem } from './queueHistoryContracts'; @@ -1825,11 +1826,11 @@ const getNextProjectIndex = (projects: Project[]): number => { return Math.max(0, ...usedIndices) + 1; }; -/** Use collision-resistant ids so a draft autosave cannot overwrite an existing server project. */ +/** Project ids are global across accounts, so a draft takes a UUID one like every other new project. */ export const createDraftProject = (projects: Project[], account?: WorkbenchState['account']): Project => createProject( getNextProjectIndex(projects), - createId('project'), + createProjectId(), account ? resolveSavedLayoutPreset(normalizeWorkbenchAccount(account), defaultLayoutPreset.id) : defaultLayoutPreset ); diff --git a/tests/app/routers/test_projects.py b/tests/app/routers/test_projects.py index 43393c2de7d..3f3bcc5b44c 100644 --- a/tests/app/routers/test_projects.py +++ b/tests/app/routers/test_projects.py @@ -380,6 +380,35 @@ def test_projects_stay_private_to_their_owner(client: TestClient, user1_token: s ) +def test_admins_cannot_open_someone_elses_project(client: TestClient, user1_token: str, admin_token: str): + project_id = _create_project(client, user1_token).json()["project_id"] + + assert client.get(f"/api/v1/projects/{project_id}", headers=_auth(admin_token)).status_code == ( + status.HTTP_404_NOT_FOUND + ) + + +def test_a_project_id_another_account_holds_is_a_409_that_changes_nothing( + client: TestClient, user1_token: str, user2_token: str +): + """Project ids are global. Taking one that is already held is refused as a conflict; the holder's project and + the refused caller's boards are as they were.""" + held = _create_project(client, user1_token, name="Held").json() + adoptable = _create_board(client, user2_token, "Imported") + boards_before = client.get("/api/v1/boards/?all=true", headers=_auth(user2_token)).json() + + for claim in ({}, {"board_id": adoptable}): + refused = _create_project(client, user2_token, name="Taken", project_id=held["project_id"], **claim) + assert refused.status_code == status.HTTP_409_CONFLICT, claim + + assert client.get(f"/api/v1/projects/{held['project_id']}", headers=_auth(user2_token)).status_code == ( + status.HTTP_404_NOT_FOUND + ) + kept = client.get(f"/api/v1/projects/{held['project_id']}", headers=_auth(user1_token)).json() + assert (kept["name"], kept["revision"]) == ("Held", held["revision"]) + assert client.get("/api/v1/boards/?all=true", headers=_auth(user2_token)).json() == boards_before + + def _create_board_in_project(client: TestClient, token: str, project_id: str, name: str) -> str: response = client.post(f"/api/v1/boards/?board_name={name}&project_id={project_id}", headers=_auth(token)) assert response.status_code == status.HTTP_201_CREATED diff --git a/tests/app/routers/test_session_queue_origin_scope.py b/tests/app/routers/test_session_queue_origin_scope.py new file mode 100644 index 00000000000..24500b4d916 --- /dev/null +++ b/tests/app/routers/test_session_queue_origin_scope.py @@ -0,0 +1,105 @@ +"""A project's queue items are its owner's: reads scoped by an origin prefix return a non-admin only their own +items, so another account's items cannot join the project's view, nor can a prefix probe for other accounts' +project ids.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus +from invokeai.app.services.session_queue.session_queue_default import SessionQueue +from invokeai.app.services.shared.database.database import Database +from tests.app.routers.conftest import MockApiDependencies, _auth +from tests.app.services.session_queue.test_session_queue_status_user_scoping import _insert_queue_item +from tests.fixtures.sqlite_database import sqlite_cursor_of + +PROJECT = "webv2:p:project-1:q:" + + +@pytest.fixture +def queue( + monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, mock_sqlite_database: Database, enable_multiuser: Any +) -> SessionQueue: + queue = SessionQueue(mock_sqlite_database) + queue.start(mock_invoker) + mock_invoker.services.session_queue = queue + mock_invoker.services.session_processor = MagicMock() + mock_invoker.services.session_processor.get_status.return_value = SessionProcessorStatus( + is_started=True, is_processing=True + ) + monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", MockApiDependencies(mock_invoker)) + return queue + + +def _user_id(client: TestClient, token: str) -> str: + return client.get("/api/v1/auth/me", headers=_auth(token)).json()["user_id"] + + +def _put(queue: SessionQueue, user_id: str, status: str) -> int: + item_id = _insert_queue_item(queue, user_id=user_id, origin=f"{PROJECT}{status}") + with sqlite_cursor_of(queue) as cursor: + cursor.execute("UPDATE session_queue SET status = ? WHERE item_id = ?", (status, item_id)) + return item_id + + +def _project_view(client: TestClient, token: str) -> dict[str, Any]: + params = {"origin_prefix": PROJECT} + status = client.get("/api/v1/queue/default/status", params=params, headers=_auth(token)).json()["queue"] + current = client.get("/api/v1/queue/default/current", params=params, headers=_auth(token)).json() + next_item = client.get("/api/v1/queue/default/next", params=params, headers=_auth(token)).json() + ids = client.get("/api/v1/queue/default/item_ids", params=params, headers=_auth(token)).json()["item_ids"] + return { + "ids": sorted(ids), + "pending": status["pending"], + "in_progress": status["in_progress"], + "current": current and current["item_id"], + "status_current": status["item_id"], + "next": next_item and next_item["item_id"], + } + + +def test_an_origin_scoped_read_shows_a_non_admin_only_their_own_items( + client: TestClient, queue: SessionQueue, user1_token: str, user2_token: str, admin_token: str +) -> None: + owner, intruder = _user_id(client, user1_token), _user_id(client, user2_token) + # Another account's items under the owner's project origin, as anyone could enqueue them, and enqueued first + # (with the higher priority) so that an unscoped read would pick them. + other_running, other_pending = _put(queue, intruder, "in_progress"), _put(queue, intruder, "pending") + own_running, own_pending = _put(queue, owner, "in_progress"), _put(queue, owner, "pending") + with sqlite_cursor_of(queue) as cursor: + cursor.execute("UPDATE session_queue SET priority = 1 WHERE item_id = ?", (other_pending,)) + + assert _project_view(client, user1_token) == { + "ids": [own_running, own_pending], + "pending": 1, + "in_progress": 1, + "current": own_running, + "status_current": own_running, + "next": own_pending, + } + assert _project_view(client, user2_token)["ids"] == [other_running, other_pending] + # Admins see the whole queue as before. + assert _project_view(client, admin_token) == { + "ids": [other_running, other_pending, own_running, own_pending], + "pending": 2, + "in_progress": 2, + "current": other_running, + "status_current": other_running, + "next": other_pending, + } + + +def test_the_unscoped_queue_list_still_lists_every_accounts_items( + client: TestClient, queue: SessionQueue, user1_token: str, user2_token: str +) -> None: + mine, theirs = ( + _put(queue, _user_id(client, user1_token), "pending"), + _put(queue, _user_id(client, user2_token), "pending"), + ) + + ids = client.get("/api/v1/queue/default/item_ids", headers=_auth(user1_token)).json()["item_ids"] + + assert sorted(ids) == [mine, theirs] diff --git a/tests/app/services/session_queue/test_session_queue_list_queue_items.py b/tests/app/services/session_queue/test_session_queue_list_queue_items.py index 01055d7ff60..ce517f2d3ed 100644 --- a/tests/app/services/session_queue/test_session_queue_list_queue_items.py +++ b/tests/app/services/session_queue/test_session_queue_list_queue_items.py @@ -80,10 +80,10 @@ def _insert_listed(session_queue: SessionQueue, queue_id: str, created_at: str, (SQLiteDirection.Ascending, {}, [4, 1, 2, 3, 5, 6, 7]), (SQLiteDirection.Descending, {"origin_prefix": PREFIX}, [6, 7, 1, 3, 4]), (SQLiteDirection.Ascending, {"origin_prefix": PREFIX}, [4, 1, 3, 6, 7]), - (SQLiteDirection.Descending, {"user_id": "alice"}, [5, 7, 1, 3]), - (SQLiteDirection.Ascending, {"user_id": "alice"}, [1, 3, 5, 7]), - (SQLiteDirection.Descending, {"user_id": "alice", "origin_prefix": PREFIX}, [7, 1, 3]), - (SQLiteDirection.Ascending, {"user_id": "alice", "origin_prefix": PREFIX}, [1, 3, 7]), + (SQLiteDirection.Descending, {"owner_id": "alice"}, [5, 7, 1, 3]), + (SQLiteDirection.Ascending, {"owner_id": "alice"}, [1, 3, 5, 7]), + (SQLiteDirection.Descending, {"owner_id": "alice", "origin_prefix": PREFIX}, [7, 1, 3]), + (SQLiteDirection.Ascending, {"owner_id": "alice", "origin_prefix": PREFIX}, [1, 3, 7]), ], ) def test_limited_item_ids_are_the_head_of_the_filtered_order( diff --git a/tests/app/services/session_queue/test_session_queue_query_plans.py b/tests/app/services/session_queue/test_session_queue_query_plans.py index b3ef9e9302e..ac581d526f9 100644 --- a/tests/app/services/session_queue/test_session_queue_query_plans.py +++ b/tests/app/services/session_queue/test_session_queue_query_plans.py @@ -129,6 +129,43 @@ def test_queue_listing_and_counts_read_only_the_listing_index(session_queue: Ses assert counts[0].count("SEARCH session_queue") == 1, counts[0] +def test_a_non_admins_project_reads_keep_their_plans(session_queue: SessionQueue, database: Database) -> None: + """Limited to the caller's items, a project's listing and counts still read the listing index alone, and its + current and next items are still found by their status: the account's own index would read its whole history.""" + listing = _statements( + database, + lambda: session_queue.get_queue_item_ids( + "default", SQLiteDirection.Descending, owner_id="alice", origin_prefix=ORIGIN_PREFIX, limit=50 + ), + ) + status = _statements( + database, + lambda: session_queue.get_queue_status( + "default", user_id="alice", acting_user_id="alice", origin_prefix=ORIGIN_PREFIX, owner_id="alice" + ), + ) + counts = [statement for statement in status if "count(" in statement.sql] + for statement in listing + counts: + assert COVERING_LISTING_INDEX in statement.plan, statement + for statement in listing: + assert "TEMP B-TREE" not in statement.plan, statement + + current_and_next = _statements( + database, + lambda: ( + session_queue.get_current_for_api("default", origin_prefix=ORIGIN_PREFIX, owner_id="alice"), + session_queue.get_next_for_api("default", origin_prefix=ORIGIN_PREFIX, owner_id="alice"), + ), + ) + in_progress = [statement for statement in status if "count(" not in statement.sql] + for statement in current_and_next + in_progress: + assert statement.binds("alice"), statement + assert ANY_STATUS_SEEK.search(statement.plan), statement + # The counts compare the account in their SELECT too; the filters are the WHERE terms. + for statement in listing + in_progress + current_and_next: + assert not INDEXED_USER_FILTER.search(statement.sql), statement + + def _vm_steps(database: Database, read: Callable[[], object]) -> int: """Virtual machine steps (in units of 10) the statements executed during `read` take.""" steps = 0 diff --git a/tests/app/services/session_queue/test_session_queue_reads.py b/tests/app/services/session_queue/test_session_queue_reads.py index e1f26bd0cc6..6e9c8b1f7ab 100644 --- a/tests/app/services/session_queue/test_session_queue_reads.py +++ b/tests/app/services/session_queue/test_session_queue_reads.py @@ -179,10 +179,10 @@ def test_item_ids_follow_creation_and_keep_a_batch_in_enqueue_order(self, databa _item(database, queue_id="other", created_at="2026-01-04 00:00:00.000") newest_first = database.queries.session_queue.item_ids( - QUEUE, descending=True, user_id="alice", origin_prefix=None + QUEUE, descending=True, owner_id="alice", origin_prefix=None ) oldest_first = database.queries.session_queue.item_ids( - QUEUE, descending=False, user_id="alice", origin_prefix=None + QUEUE, descending=False, owner_id="alice", origin_prefix=None ) assert newest_first == [*batch, old] @@ -193,7 +193,7 @@ def test_item_ids_by_origin(self, database: Database) -> None: _item(database, origin="canvas:1") assert database.queries.session_queue.item_ids( - QUEUE, descending=True, user_id=None, origin_prefix="WORKFLOWS" + QUEUE, descending=True, owner_id=None, origin_prefix="WORKFLOWS" ) == [wanted] def test_summaries_are_of_the_named_items_of_the_queue(self, database: Database) -> None: diff --git a/tests/app/services/session_queue/test_session_queue_status_user_scoping.py b/tests/app/services/session_queue/test_session_queue_status_user_scoping.py index 19c0e767fd7..63fe5cd22d5 100644 --- a/tests/app/services/session_queue/test_session_queue_status_user_scoping.py +++ b/tests/app/services/session_queue/test_session_queue_status_user_scoping.py @@ -12,7 +12,8 @@ A regression (#9018) scoped both of these to the calling user, which (a) collapsed the badge to a single own-count and (b) hid other users' redacted entries entirely. These tests guard the -restored behavior. +restored behavior. Reads scoped by an origin prefix are narrowed to the caller's own items at the router +(tests/app/routers/test_session_queue_origin_scope.py). """ import sqlite3 diff --git a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_04_add_session_queue_listing_index.py b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_04_add_session_queue_listing_index.py index b7d80b3164e..0987cbcbfee 100644 --- a/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_04_add_session_queue_listing_index.py +++ b/tests/app/services/shared/sqlite_migrator/migrations/test_migration_2026_10_04_add_session_queue_listing_index.py @@ -100,8 +100,8 @@ def ids(direction: SQLiteDirection, **filters: str) -> list[int]: "asc": ids(SQLiteDirection.Ascending), "desc_origin": ids(SQLiteDirection.Descending, origin_prefix=PREFIX), "asc_origin": ids(SQLiteDirection.Ascending, origin_prefix=PREFIX), - "desc_user": ids(SQLiteDirection.Descending, user_id="alice"), - "asc_user": ids(SQLiteDirection.Ascending, user_id="alice"), + "desc_user": ids(SQLiteDirection.Descending, owner_id="alice"), + "asc_user": ids(SQLiteDirection.Ascending, owner_id="alice"), "status": queue.get_queue_status("default", user_id="alice", acting_user_id="alice"), "status_origin": queue.get_queue_status("default", user_id="bob", acting_user_id="bob", origin_prefix=PREFIX), "summaries": [ From 45d0d2c7f1ba6414901d548f523b0b4fe802a096 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 11:55:43 -0600 Subject: [PATCH 04/16] feat(projects): save each member's workspace apart from project content Projects read with the caller's workspace, which saves through its own revision-checked route and indexes its media separately. A document schema floor refuses clients that predate the split; deleting a project or account drops every member's workspace references. Document the content and workspace contract. --- .../development/Front End/project-files.mdx | 14 + invokeai/app/api/routers/projects.py | 127 +++++++- invokeai/app/api_app.py | 3 +- .../intermediates/intermediates_common.py | 3 +- .../intermediates_records_default.py | 5 +- .../project_records/project_records_base.py | 49 ++- .../project_records/project_records_common.py | 79 ++++- .../project_records_default.py | 112 ++++++- .../shared/database/queries/intermediates.py | 4 + .../database/queries/media_references.py | 9 + .../shared/database/queries/projects.py | 130 ++++++-- .../app/services/shared/media_references.py | 2 +- invokeai/app/services/users/users_default.py | 8 +- invokeai/frontend/api/openapi.json | 304 +++++++++++++++++- invokeai/frontend/api/schema.ts | 223 ++++++++++++- .../frontend/webv2/public/locales/en.json | 2 + .../src/features/intermediates/core/types.ts | 12 +- .../features/intermediates/ui/ClearDialog.tsx | 1 + .../ui/IntermediatesManager.browser.test.tsx | 2 + tests/app/api/test_project_write_limits.py | 12 +- tests/app/routers/test_projects.py | 105 ++++++ .../test_intermediates_service.py | 19 ++ .../project_records/test_project_records.py | 8 +- .../test_project_workspaces.py | 304 ++++++++++++++++++ tests/app/services/users/test_user_service.py | 25 +- 25 files changed, 1491 insertions(+), 71 deletions(-) create mode 100644 tests/app/services/project_records/test_project_workspaces.py diff --git a/docs/src/content/docs/development/Front End/project-files.mdx b/docs/src/content/docs/development/Front End/project-files.mdx index 4991fca692e..68a9d0f36da 100644 --- a/docs/src/content/docs/development/Front End/project-files.mdx +++ b/docs/src/content/docs/development/Front End/project-files.mdx @@ -66,6 +66,20 @@ client from opening a v3 project: it is treated as v2, not as unrestricted. The forward-version gate and the project API adapter consume the same constant so advertised support cannot drift from actual parsing support. +## Project content and workspaces + +From document schema 4 a project is two documents. The server stores and serves both halves; webv2 still saves whole schema 3 documents through `PUT /projects/{id}` until it adopts the split, and in 7.0 only a project's owner can save a workspace in it (anyone else gets `404`). Its **content** is what the project is: the shared work a collaborator would review, export or edit. Each member's **workspace** is how they work on it: layout, widgets and their generation settings, and their selection and staging. The content stays in `projects.data`; each workspace is a row of `project_workspaces`, keyed by project and account, which deleting either removes. + +- `GET /projects/{id}` returns the content and, as `workspace`, the caller's workspace with its own `revision` (or `null` before they have saved one). +- `PUT /projects/{id}` saves the content, revision-checked as before. +- `PUT /projects/{id}/workspace` saves the caller's workspace against its own `expected_revision` (`null` for the first save); a mismatch is `409` with the `current_revision`. The two halves never conflict with each other, and no request saves both. +- Both halves have the 32 MiB limit, and both pass through the project write limiter, which runs one write per account at a time, so a client saves the halves one after the other. +- The listing gives each project's `workspace_updated_at` for the caller beside the content's `updated_at`. + +Each half indexes the media it names separately (`project` and `project_workspace` references), so saving one never drops what only the other names, such as a staged candidate the intermediates cleanup would otherwise delete. + +`minimum_document_schema_version` is a second floor beside the canvas one, with the same rules: reads declare `max_document_schema_version` as a query parameter and content saves in their body (both default to 3; workspace saves must declare it), a save may raise the floor but not above its own maximum and never lower it (`400` with the code `document_schema_downgrade`), and a client below it gets `412` with the code `document_schema_unsupported`. Projects saved before the split are at 3; the first save that splits one raises it to 4, so a client that predates the split, which would read the content as a whole document and save it back without the workspace, is refused instead. + ## Custom fonts Webv2 exports font dependencies in the manifest's optional `fonts` array. Each dependency records a SHA-256 content hash, readable family and face labels, source reference IDs, and an optional `fonts/.` entry. The Canvas schema compatibility floor protects the newer editable text fields; the ZIP manifest remains version 2. diff --git a/invokeai/app/api/routers/projects.py b/invokeai/app/api/routers/projects.py index 6e3aba95086..ba08786e13e 100644 --- a/invokeai/app/api/routers/projects.py +++ b/invokeai/app/api/routers/projects.py @@ -8,6 +8,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_DOCUMENT_MAX_BYTES, ProjectBoardNotFoundError, ProjectBoardSnapshotDTO, @@ -16,12 +17,17 @@ ProjectCanvasSchemaDowngradeError, ProjectCanvasSchemaUnsupportedError, ProjectDocumentInvalidError, + ProjectDocumentSchemaDowngradeError, + ProjectDocumentSchemaUnsupportedError, ProjectDocumentTooLargeError, ProjectRecordConflictError, ProjectRecordDTO, ProjectRecordExistsError, ProjectRecordNotFoundError, ProjectSummaryDTO, + ProjectWithWorkspaceDTO, + ProjectWorkspaceConflictError, + ProjectWorkspaceDTO, ) projects_router = APIRouter(prefix="/v1/projects", tags=["projects"]) @@ -61,6 +67,16 @@ class ProjectCreateRequest(BaseModel): ge=1, description="Newest canvas schema understood by this client", ) + minimum_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Oldest project document schema that can read this document", + ) + max_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ) class ProjectUpdateRequest(BaseModel): @@ -79,6 +95,55 @@ class ProjectUpdateRequest(BaseModel): ge=1, description="Newest canvas schema understood by this client", ) + minimum_document_schema_version: int | None = Field( + default=None, + ge=1, + description=( + "New document schema floor to store atomically with this content; omitted to keep the current floor" + ), + ) + max_document_schema_version: int = Field( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ) + + +class ProjectWorkspaceSaveRequest(BaseModel): + """Request body for saving the caller's workspace in a project with optimistic concurrency.""" + + data: dict[str, Any] = Field(description="The opaque client-owned workspace document") + expected_revision: int | None = Field( + description="The workspace revision this save is based on, or null for the caller's first workspace in the" + " project; a mismatch returns 409" + ) + max_document_schema_version: int = Field( + ge=1, description="Newest project document schema understood by this client" + ) + + +def _document_schema_precondition_failed(error: ProjectDocumentSchemaUnsupportedError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail={ + "code": "document_schema_unsupported", + "message": str(error), + "minimum_document_schema_version": error.minimum_version, + "max_document_schema_version": error.client_maximum_version, + }, + ) + + +def _document_schema_downgrade(error: ProjectDocumentSchemaDowngradeError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "document_schema_downgrade", + "message": str(error), + "current_minimum_document_schema_version": error.current_version, + "requested_minimum_document_schema_version": error.requested_version, + }, + ) def _schema_precondition_failed(error: ProjectCanvasSchemaUnsupportedError) -> HTTPException: @@ -135,6 +200,8 @@ def create_project( board_id=request.board_id, minimum_canvas_schema_version=request.minimum_canvas_schema_version, max_canvas_schema_version=request.max_canvas_schema_version, + minimum_document_schema_version=request.minimum_document_schema_version, + max_document_schema_version=request.max_document_schema_version, ) except (ProjectRecordExistsError, ProjectBoardUnavailableError) as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) @@ -142,13 +209,15 @@ def create_project( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) except ProjectDocumentTooLargeError as e: raise _document_too_large(e) except ProjectDocumentInvalidError as e: raise _document_invalid(e) -@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectRecordDTO) +@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectWithWorkspaceDTO) def get_project( current_user: CurrentUserOrDefault, project_id: str = Path(description="The id of the project to get"), @@ -157,16 +226,26 @@ def get_project( ge=1, description="Newest canvas schema understood by this client", ), -) -> ProjectRecordDTO: - """Gets one of the current user's projects, including its document.""" + max_document_schema_version: int = Query( + default=DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ge=1, + description="Newest project document schema understood by this client", + ), +) -> ProjectWithWorkspaceDTO: + """Gets one of the current user's projects: its content and the caller's workspace in it.""" try: return ApiDependencies.invoker.services.project_records.get( - current_user.user_id, project_id, max_canvas_schema_version=max_canvas_schema_version + current_user.user_id, + project_id, + max_canvas_schema_version=max_canvas_schema_version, + max_document_schema_version=max_document_schema_version, ) except ProjectRecordNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) @projects_router.put("/{project_id}", operation_id="update_project", response_model=ProjectRecordDTO) @@ -185,6 +264,8 @@ def update_project( data=request.data, minimum_canvas_schema_version=request.minimum_canvas_schema_version, max_canvas_schema_version=request.max_canvas_schema_version, + minimum_document_schema_version=request.minimum_document_schema_version, + max_document_schema_version=request.max_document_schema_version, ) except ProjectRecordNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -203,8 +284,46 @@ def update_project( "requested_minimum_canvas_schema_version": e.requested_version, }, ) + except ProjectDocumentSchemaDowngradeError as e: + raise _document_schema_downgrade(e) except ProjectCanvasSchemaUnsupportedError as e: raise _schema_precondition_failed(e) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) + except ProjectDocumentTooLargeError as e: + raise _document_too_large(e) + except ProjectDocumentInvalidError as e: + raise _document_invalid(e) + + +@projects_router.put( + "/{project_id}/workspace", operation_id="save_project_workspace", response_model=ProjectWorkspaceDTO +) +def save_project_workspace( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project whose workspace to save"), + request: ProjectWorkspaceSaveRequest = Body(description="The workspace and the revision it is based on"), +) -> ProjectWorkspaceDTO: + """Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart + from the project content, so that saving one never conflicts with the other. Returns 409 with the current + revision (null when there is none) when the save is based on another.""" + try: + return ApiDependencies.invoker.services.project_records.save_workspace( + user_id=current_user.user_id, + project_id=project_id, + expected_revision=request.expected_revision, + data=request.data, + max_document_schema_version=request.max_document_schema_version, + ) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ProjectWorkspaceConflictError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": str(e), "current_revision": e.current_revision}, + ) + except ProjectDocumentSchemaUnsupportedError as e: + raise _document_schema_precondition_failed(e) except ProjectDocumentTooLargeError as e: raise _document_too_large(e) except ProjectDocumentInvalidError as e: diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index ee0b722fe0f..3e268b89751 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -634,7 +634,8 @@ def _is_project_write(method: str, path: str) -> bool: return path == "/api/v1/projects/" if method != "PUT" or not path.startswith("/api/v1/projects/"): return False - project_id = path.removeprefix("/api/v1/projects/") + # A project's content, or the caller's workspace in it: each is a document as large. + project_id = path.removeprefix("/api/v1/projects/").removesuffix("/workspace") return bool(project_id) and "/" not in project_id diff --git a/invokeai/app/services/intermediates/intermediates_common.py b/invokeai/app/services/intermediates/intermediates_common.py index f1402fafabf..33cadfd48c3 100644 --- a/invokeai/app/services/intermediates/intermediates_common.py +++ b/invokeai/app/services/intermediates/intermediates_common.py @@ -159,7 +159,8 @@ class IntermediatesAffectedDocument(BaseModel): kind: MediaReferenceOwnerKind = Field( description=( - "`client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" + "`project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's" + " persisted state; `quarantined_project` is a project kept for repair" ) ) user_id: str diff --git a/invokeai/app/services/intermediates/intermediates_records_default.py b/invokeai/app/services/intermediates/intermediates_records_default.py index cc82c869797..ee901e9c394 100644 --- a/invokeai/app/services/intermediates/intermediates_records_default.py +++ b/invokeai/app/services/intermediates/intermediates_records_default.py @@ -449,7 +449,7 @@ def get_document_names(self, owners: Collection[ReferenceOwner]) -> dict[Referen """Names of the referencing documents, one query per kind; client state has none.""" wanted: dict[str, set[ReferenceOwner]] = defaultdict(set) for owner in owners: - if owner.owner_kind in ("project", "quarantined_project", "workflow"): + if owner.owner_kind in ("project", "project_workspace", "quarantined_project", "workflow"): wanted[owner.owner_kind].add(owner) def names(q: Queries) -> dict[ReferenceOwner, str]: @@ -462,7 +462,8 @@ def names(q: Queries) -> dict[ReferenceOwner, str]: if name is not None } for owner in kind_owners: - name = named.get((None if owner_kind == "workflow" else owner.user_id, owner.owner_id)) + unowned = owner_kind in ("workflow", "project_workspace") + name = named.get((None if unowned else owner.user_id, owner.owner_id)) if name is not None: found[owner] = name return found diff --git a/invokeai/app/services/project_records/project_records_base.py b/invokeai/app/services/project_records/project_records_base.py index faf1a95b5d2..669454f7e9d 100644 --- a/invokeai/app/services/project_records/project_records_base.py +++ b/invokeai/app/services/project_records/project_records_base.py @@ -1,11 +1,14 @@ from abc import ABC, abstractmethod -from typing import Any, Literal +from typing import Any, Literal, Optional from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ProjectBoardSnapshotDTO, ProjectRecordDTO, ProjectSummaryDTO, + ProjectWithWorkspaceDTO, + ProjectWorkspaceDTO, ) @@ -21,6 +24,10 @@ class ProjectRecordsStorageBase(ABC): always commit together. The project's other boards are ordinary boards whose `project_id` names it; they are created and moved through the board service and only leave with the project, released to the Library or deleted. + + From document schema 4 a project is two documents: its content, which the + project saves, and each member's workspace in it, saved apart with a revision + of its own. The storage parses neither beyond the media they name. """ @abstractmethod @@ -33,6 +40,8 @@ def create( board_id: str | None = None, minimum_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ) -> ProjectRecordDTO: """Create a project for the user, with its inbox. @@ -47,6 +56,8 @@ def create( import. minimum_canvas_schema_version: Compatibility floor stored with the project. max_canvas_schema_version: Newest canvas schema understood by the caller. + minimum_document_schema_version: Document schema floor stored with the project. + max_document_schema_version: Newest document schema understood by the caller. Returns: The created project record. @@ -56,6 +67,7 @@ def create( ProjectBoardNotFoundError: `board_id` is missing or belongs to another user. ProjectBoardUnavailableError: The board is public, shared, or already claimed. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the requested schema. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the requested document schema. ProjectDocumentInvalidError: The document is not standards-compliant UTF-8 JSON. ProjectDocumentTooLargeError: The project document exceeds the storage limit. """ @@ -67,12 +79,14 @@ def get( user_id: str, project_id: str, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - """Get one of the user's projects, including its document. + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ) -> ProjectWithWorkspaceDTO: + """Get one of the user's projects: its content, and the user's workspace in it if they have saved one. Raises: ProjectRecordNotFoundError: No such project for this user. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the stored schema. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the stored document schema. """ pass @@ -91,8 +105,10 @@ def update( data: dict[str, Any], minimum_canvas_schema_version: int | None = None, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: int | None = None, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ) -> ProjectRecordDTO: - """Save a project if the caller's revision is current, renaming its inbox to match. + """Save a project's content if the caller's revision is current, renaming its inbox to match. A save that loses the revision race renames nothing. @@ -101,11 +117,36 @@ def update( ProjectRecordConflictError: The stored revision differs from expected_revision. ProjectCanvasSchemaUnsupportedError: The caller cannot safely edit the stored or requested schema. ProjectCanvasSchemaDowngradeError: The save tries to lower the stored compatibility floor. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the stored or requested document + schema. + ProjectDocumentSchemaDowngradeError: The save tries to lower the stored document schema floor. ProjectDocumentInvalidError: The document is not standards-compliant UTF-8 JSON. ProjectDocumentTooLargeError: The project document exceeds the storage limit. """ pass + @abstractmethod + def save_workspace( + self, + user_id: str, + project_id: str, + expected_revision: Optional[int], + data: dict[str, Any], + max_document_schema_version: int, + ) -> ProjectWorkspaceDTO: + """Save the user's workspace in one of their projects if `expected_revision` is its current revision, or + None and they have none yet. Independent of the content's revision: neither half's save conflicts with the + other's. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + ProjectWorkspaceConflictError: The stored workspace revision (or its absence) differs from the expected. + ProjectDocumentSchemaUnsupportedError: The caller does not understand the project's document schema. + ProjectDocumentInvalidError: The workspace is not standards-compliant UTF-8 JSON. + ProjectDocumentTooLargeError: The workspace exceeds the storage limit. + """ + pass + @abstractmethod def delete(self, user_id: str, project_id: str, boards: Literal["release", "delete"] = "release") -> None: """Delete one of the user's projects and its inbox, in one transaction. diff --git a/invokeai/app/services/project_records/project_records_common.py b/invokeai/app/services/project_records/project_records_common.py index 2b0a173bce9..aa8c3f6b73e 100644 --- a/invokeai/app/services/project_records/project_records_common.py +++ b/invokeai/app/services/project_records/project_records_common.py @@ -1,6 +1,6 @@ """Common types and errors for the project records service.""" -from typing import Any, Literal +from typing import Any, Literal, Optional from pydantic import BaseModel, Field @@ -14,6 +14,16 @@ """ +DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION = 3 +"""The document schema floor of projects saved before the floor existed, and the newest schema a client that does +not say is assumed to understand. + +Schema 4 split the document into the project content, in `projects.data`, and each member's workspace, in +`project_workspaces`. A client that does not name the newest schema it understands predates the split, so it is +refused a split project rather than handed half of one. +""" + + class ProjectRecordNotFoundError(Exception): """Raised when a project record is not found for the requesting user.""" @@ -22,7 +32,7 @@ def __init__(self, project_id: str) -> None: class ProjectRecordExistsError(Exception): - """Raised when creating a project with an id the user already has.""" + """Raised when creating a project with an id a project already has: ids are unique across accounts.""" def __init__(self, project_id: str) -> None: super().__init__(f"Project {project_id} already exists") @@ -71,6 +81,44 @@ def __init__(self, project_id: str, minimum_version: int, client_maximum_version ) +class ProjectDocumentSchemaUnsupportedError(Exception): + """Raised before a client may read or write a project whose document schema it does not understand.""" + + def __init__(self, project_id: str, minimum_version: int, client_maximum_version: int) -> None: + self.project_id = project_id + self.minimum_version = minimum_version + self.client_maximum_version = client_maximum_version + super().__init__( + f"Project {project_id} requires document schema {minimum_version}, but this client supports up to" + f" schema {client_maximum_version}. Update the client before opening or editing this project." + ) + + +class ProjectDocumentSchemaDowngradeError(Exception): + """Raised when a save tries to lower a project's document schema floor.""" + + def __init__(self, project_id: str, current_version: int, requested_version: int) -> None: + self.project_id = project_id + self.current_version = current_version + self.requested_version = requested_version + super().__init__( + f"Project {project_id} requires document schema {current_version}; its floor cannot be lowered to" + f" schema {requested_version}" + ) + + +class ProjectWorkspaceConflictError(Exception): + """Raised when a workspace save is based on a revision other than the stored one (another tab or device saved + first), or expects a workspace where there is none or none where there is one.""" + + def __init__(self, project_id: str, expected_revision: Optional[int], current_revision: Optional[int]) -> None: + self.current_revision = current_revision + super().__init__( + f"The workspace in project {project_id} is at revision {current_revision}; the save expected revision" + f" {expected_revision}" + ) + + class ProjectCanvasSchemaDowngradeError(Exception): """Raised when a save tries to lower a project's compatibility floor.""" @@ -134,14 +182,37 @@ class ProjectSummaryDTO(BaseModel): minimum_canvas_schema_version: int = Field( ge=1, description="Oldest canvas schema a client must support before it may read or write this project" ) + minimum_document_schema_version: int = Field( + ge=1, + description="Oldest project document schema a client must support before it may read or write this project", + ) created_at: str = Field(description="When the project was created") - updated_at: str = Field(description="When the project was last saved") + updated_at: str = Field(description="When the project content was last saved") + workspace_updated_at: Optional[str] = Field( + default=None, description="When the caller last saved their workspace in the project; null if they have none" + ) class ProjectRecordDTO(ProjectSummaryDTO): """Full project record including the client-owned document.""" - data: dict[str, Any] = Field(description="The opaque client-owned project document") + data: dict[str, Any] = Field(description="The opaque client-owned project content (document schema 4 on)") + + +class ProjectWorkspaceDTO(BaseModel): + """One account's workspace in a project: how they work on it (layout, widgets, generation settings).""" + + data: dict[str, Any] = Field(description="The opaque client-owned workspace document") + revision: int = Field(description="Monotonic revision, incremented on every save of this workspace") + updated_at: str = Field(description="When the workspace was last saved") + + +class ProjectWithWorkspaceDTO(ProjectRecordDTO): + """A project's content together with the caller's workspace in it.""" + + workspace: Optional[ProjectWorkspaceDTO] = Field( + description="The caller's workspace; null until they save one, when the client builds a default" + ) class ProjectBoardItemDTO(BaseModel): diff --git a/invokeai/app/services/project_records/project_records_default.py b/invokeai/app/services/project_records/project_records_default.py index 46c7b99e985..36cbe4e5fa4 100644 --- a/invokeai/app/services/project_records/project_records_default.py +++ b/invokeai/app/services/project_records/project_records_default.py @@ -7,6 +7,7 @@ from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase from invokeai.app.services.project_records.project_records_common import ( DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, PROJECT_BOARD_SNAPSHOT_MAX_ITEMS, PROJECT_DOCUMENT_MAX_BYTES, ProjectBoardNotFoundError, @@ -16,12 +17,17 @@ ProjectCanvasSchemaDowngradeError, ProjectCanvasSchemaUnsupportedError, ProjectDocumentInvalidError, + ProjectDocumentSchemaDowngradeError, + ProjectDocumentSchemaUnsupportedError, ProjectDocumentTooLargeError, ProjectRecordConflictError, ProjectRecordDTO, ProjectRecordExistsError, ProjectRecordNotFoundError, ProjectSummaryDTO, + ProjectWithWorkspaceDTO, + ProjectWorkspaceConflictError, + ProjectWorkspaceDTO, ) from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.errors import UniqueViolation @@ -53,6 +59,26 @@ def _require_supported_schema(*, project_id: str, minimum_version: int, client_m ) +def _require_supported_document_schema(*, project_id: str, minimum_version: int, client_maximum_version: int) -> None: + if client_maximum_version < minimum_version: + raise ProjectDocumentSchemaUnsupportedError( + project_id=project_id, + minimum_version=minimum_version, + client_maximum_version=client_maximum_version, + ) + + +def _next_document_floor(project_id: str, current: int, requested: Optional[int]) -> int: + """The document schema floor a save leaves: the requested one, which may only raise it, or the current one.""" + if requested is None: + return current + if requested < current: + raise ProjectDocumentSchemaDowngradeError( + project_id=project_id, current_version=current, requested_version=requested + ) + return requested + + def _record(summary: Optional[ProjectSummaryDTO], project_id: str, data: dict[str, Any]) -> ProjectRecordDTO: """The record a write returns: its row as the write left it, read in the write's transaction, with the document the write stored. Not read back: the document is the largest part of the row, and decoding it again would take @@ -124,6 +150,8 @@ def create( board_id: str | None = None, minimum_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ) -> ProjectRecordDTO: new_project_id = project_id or uuid.uuid4().hex _require_supported_schema( @@ -131,6 +159,11 @@ def create( minimum_version=minimum_canvas_schema_version, client_maximum_version=max_canvas_schema_version, ) + _require_supported_document_schema( + project_id=new_project_id, + minimum_version=minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) document_json, document_bytes = _serialize_project_document(data) _require_project_document_size(document_bytes) references = extract_media_references(data) @@ -156,6 +189,7 @@ def insert(q: Queries) -> Optional[ProjectSummaryDTO]: data=document_json, board_id=project_board_id, minimum_canvas_schema_version=minimum_canvas_schema_version, + minimum_document_schema_version=minimum_document_schema_version, ) except UniqueViolation as error: # A project has this id: the account's own, created meanwhile or, when the create brings no board, @@ -177,8 +211,13 @@ def get( user_id: str, project_id: str, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, - ) -> ProjectRecordDTO: - record = self._queries.projects.get(user_id, project_id) + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, + ) -> ProjectWithWorkspaceDTO: + def read(q: Queries) -> tuple[Optional[ProjectRecordDTO], Optional[ProjectWorkspaceDTO]]: + # One snapshot: both rows as they stood at one instant, though each half is saved on its own. + return q.projects.get(user_id, project_id), q.projects.workspace(user_id, project_id) + + record, workspace = self._queries.run(read, read_only=True) if record is None: raise ProjectRecordNotFoundError(project_id) _require_supported_schema( @@ -186,7 +225,13 @@ def get( minimum_version=record.minimum_canvas_schema_version, client_maximum_version=max_canvas_schema_version, ) - return record + _require_supported_document_schema( + project_id=project_id, + minimum_version=record.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) + # Shallow: `model_dump` would copy the whole decoded content, which can run to tens of megabytes. + return ProjectWithWorkspaceDTO(**dict(record), workspace=workspace) def list(self, user_id: str) -> list[ProjectSummaryDTO]: return self._queries.projects.summaries(user_id) @@ -200,6 +245,8 @@ def update( data: dict[str, Any], minimum_canvas_schema_version: int | None = None, max_canvas_schema_version: int = DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: int | None = None, + max_document_schema_version: int = DEFAULT_PROJECT_DOCUMENT_SCHEMA_VERSION, ) -> ProjectRecordDTO: document_json, document_bytes = _serialize_project_document(data) references = extract_media_references(data) @@ -217,6 +264,11 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: minimum_version=current_minimum_version, client_maximum_version=max_canvas_schema_version, ) + _require_supported_document_schema( + project_id=project_id, + minimum_version=project.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) if project.revision != expected_revision: raise ProjectRecordConflictError(project_id, expected_revision, project.revision) next_minimum_version = ( @@ -233,6 +285,14 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: minimum_version=next_minimum_version, client_maximum_version=max_canvas_schema_version, ) + next_document_floor = _next_document_floor( + project_id, project.minimum_document_schema_version, minimum_document_schema_version + ) + _require_supported_document_schema( + project_id=project_id, + minimum_version=next_document_floor, + client_maximum_version=max_document_schema_version, + ) _require_project_document_size(document_bytes) q.projects.save( @@ -241,6 +301,7 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: name=name, data=document_json, minimum_canvas_schema_version=next_minimum_version, + minimum_document_schema_version=next_document_floor, ) q.boards.rename(project.board_id, name[:BOARD_NAME_MAX_LENGTH]) q.media_references.replace( @@ -250,6 +311,48 @@ def save(q: Queries) -> Optional[ProjectSummaryDTO]: return _record(self._queries.run(save), project_id, data) + def save_workspace( + self, + user_id: str, + project_id: str, + expected_revision: Optional[int], + data: dict[str, Any], + max_document_schema_version: int, + ) -> ProjectWorkspaceDTO: + document_json, document_bytes = _serialize_project_document(data) + _require_project_document_size(document_bytes) + references = extract_media_references(data) + + def save(q: Queries) -> Optional[tuple[int, str]]: + q.locks.acquire(DatabaseLock.MEDIA_PROTECTION, shared=True) + # The project's row is the lock for its workspaces too: every workspace write takes it first, so the + # revision read below is the one this write replaces, and a deletion of the project waits for it. + project = q.projects.lock(user_id, project_id) + if project is None: + raise ProjectRecordNotFoundError(project_id) + _require_supported_document_schema( + project_id=project_id, + minimum_version=project.minimum_document_schema_version, + client_maximum_version=max_document_schema_version, + ) + current = q.projects.workspace_stamp(user_id, project_id) + current_revision = current[0] if current is not None else None + if current_revision != expected_revision: + raise ProjectWorkspaceConflictError(project_id, expected_revision, current_revision) + if current_revision is None: + q.projects.insert_workspace(user_id=user_id, project_id=project_id, data=document_json) + else: + q.projects.save_workspace(user_id=user_id, project_id=project_id, data=document_json) + q.media_references.replace( + owner_kind="project_workspace", user_id=user_id, owner_id=project_id, references=references + ) + return q.projects.workspace_stamp(user_id, project_id) + + stamp = self._queries.run(save) + assert stamp is not None, "the workspace was written in this transaction" + # The document the client sent, not a decode of the stored one: the same JSON, and the larger part. + return ProjectWorkspaceDTO(data=data, revision=stamp[0], updated_at=stamp[1]) + def delete(self, user_id: str, project_id: str, boards: Literal["release", "delete"] = "release") -> None: def delete(q: Queries) -> None: # The project's row, then its board's, as every write of a project locks them: the board deleted below @@ -258,9 +361,12 @@ def delete(q: Queries) -> None: if project is None: return q.boards.lock(project.board_id) + # Read before the project's row goes, and its workspaces with it; its lock keeps new ones from being saved. + members = q.projects.workspace_members(project_id) q.boards.remove_project_members(user_id, project_id, project.board_id, delete_members=boards == "delete") q.projects.delete(user_id, project_id) q.media_references.delete(owner_kind="project", user_id=user_id, owner_id=project_id) + q.media_references.delete_many_owners(owner_kind="project_workspace", user_ids=members, owner_id=project_id) # Only after the project is gone: a claimed board cannot be deleted. Deleting the board deletes its # memberships, returning the media to Uncategorized; the image and video records and their files are # untouched. diff --git a/invokeai/app/services/shared/database/queries/intermediates.py b/invokeai/app/services/shared/database/queries/intermediates.py index 304a77639ef..53ebed3eba6 100644 --- a/invokeai/app/services/shared/database/queries/intermediates.py +++ b/invokeai/app/services/shared/database/queries/intermediates.py @@ -135,6 +135,10 @@ def _protected_items() -> Subquery: _DOCUMENT_NAMES = { "project": select(_P.user_id, _P.project_id, _P.name).where(_P.project_id.in_(bindparam("ids", expanding=True))), + # A workspace is named after its project, whose owner need not be the workspace's. + "project_workspace": select(null(), _P.project_id, _P.name).where( + _P.project_id.in_(bindparam("ids", expanding=True)) + ), "quarantined_project": select( orphaned_projects_2026_08_06.c.user_id, orphaned_projects_2026_08_06.c.project_id, diff --git a/invokeai/app/services/shared/database/queries/media_references.py b/invokeai/app/services/shared/database/queries/media_references.py index d7cecf90307..4b8edf344e5 100644 --- a/invokeai/app/services/shared/database/queries/media_references.py +++ b/invokeai/app/services/shared/database/queries/media_references.py @@ -55,6 +55,15 @@ def replace( def delete(self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_id: str, owner_id: str) -> None: conn.execute(_DELETE_OWNER, {"owner_kind": owner_kind, "user_id": user_id, "owner_id": owner_id}) + @write + def delete_many_owners( + self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_ids: Collection[str], owner_id: str + ) -> None: + """Deletes the references several accounts hold through one document, such as each member's workspace in a + project: by key, not by a scan of every such document's references.""" + for user_id in user_ids: + conn.execute(_DELETE_OWNER, {"owner_kind": owner_kind, "user_id": user_id, "owner_id": owner_id}) + @write def delete_many( self, conn: Connection, *, owner_kind: MediaReferenceOwnerKind, user_id: str, owner_ids: Collection[str] diff --git a/invokeai/app/services/shared/database/queries/projects.py b/invokeai/app/services/shared/database/queries/projects.py index a7b8015e8db..2801fd03ff3 100644 --- a/invokeai/app/services/shared/database/queries/projects.py +++ b/invokeai/app/services/shared/database/queries/projects.py @@ -8,6 +8,7 @@ ColumnElement, Connection, Row, + and_, bindparam, case, delete, @@ -27,12 +28,13 @@ ProjectBoardSnapshotDTO, ProjectRecordDTO, ProjectSummaryDTO, + ProjectWorkspaceDTO, ) from invokeai.app.services.shared.database.dialect import OrderedJoin from invokeai.app.services.shared.database.queries.base import QueryModule, locking, mapped, read, write from invokeai.app.services.shared.database.schema.boards import board_images, board_videos, boards from invokeai.app.services.shared.database.schema.images import images -from invokeai.app.services.shared.database.schema.projects import projects +from invokeai.app.services.shared.database.schema.projects import project_workspaces, projects from invokeai.app.services.shared.database.schema.videos import videos @@ -41,35 +43,64 @@ class LockedProject(NamedTuple): revision: int minimum_canvas_schema_version: int + minimum_document_schema_version: int board_id: str +_W = project_workspaces.c _SUMMARY_COLUMNS = ( projects.c.project_id, projects.c.board_id, projects.c.name, projects.c.revision, projects.c.minimum_canvas_schema_version, + projects.c.minimum_document_schema_version, projects.c.created_at, projects.c.updated_at, + _W.updated_at, +) +# The caller's workspace beside each project: `user_id` is the caller, who in 7.0 is always the owner. +_WITH_CALLERS_WORKSPACE = projects.outerjoin( + project_workspaces, and_(_W.project_id == projects.c.project_id, _W.user_id == bindparam("user_id")) ) _THE_PROJECT = (projects.c.user_id == bindparam("user_id"), projects.c.project_id == bindparam("project_id")) -_GET = select(*_SUMMARY_COLUMNS, projects.c.data).where(*_THE_PROJECT) -_SUMMARY = select(*_SUMMARY_COLUMNS).where(*_THE_PROJECT) +_GET = select(*_SUMMARY_COLUMNS, projects.c.data).select_from(_WITH_CALLERS_WORKSPACE).where(*_THE_PROJECT) +_SUMMARY = select(*_SUMMARY_COLUMNS).select_from(_WITH_CALLERS_WORKSPACE).where(*_THE_PROJECT) # Oldest first. Projects created in the same millisecond come in the order of their ids. _SUMMARIES = ( select(*_SUMMARY_COLUMNS) + .select_from(_WITH_CALLERS_WORKSPACE) .where(projects.c.user_id == bindparam("user_id")) .order_by(projects.c.created_at, projects.c.project_id) ) _BOARD_ID = select(projects.c.board_id).where(*_THE_PROJECT) _LOCK = ( - select(projects.c.revision, projects.c.minimum_canvas_schema_version, projects.c.board_id) + select( + projects.c.revision, + projects.c.minimum_canvas_schema_version, + projects.c.minimum_document_schema_version, + projects.c.board_id, + ) .where(*_THE_PROJECT) .with_for_update() ) +_THE_WORKSPACE = (_W.project_id == bindparam("project_id"), _W.user_id == bindparam("user_id")) +_WORKSPACE_MEMBERS = select(_W.user_id).where(_W.project_id == bindparam("project_id")) +_WORKSPACES_IN_OWNED = ( + select(_W.user_id, _W.project_id) + .select_from(projects.join(project_workspaces, _W.project_id == projects.c.project_id)) + .where(projects.c.user_id == bindparam("user_id")) +) +_WORKSPACE = select(_W.data, _W.revision, _W.updated_at).where(*_THE_WORKSPACE) +_WORKSPACE_STAMP = select(_W.revision, _W.updated_at).where(*_THE_WORKSPACE) +_INSERT_WORKSPACE = insert(project_workspaces) +_SAVE_WORKSPACE = ( + update(project_workspaces) + .where(_W.project_id == bindparam("target_project_id"), _W.user_id == bindparam("target_user_id")) + .values(data=bindparam("new_data"), revision=_W.revision + 1) +) _CLAIMANT = select(projects.c.user_id, projects.c.project_id).where(projects.c.board_id == bindparam("board_id")) _INSERT = insert(projects) _SAVE = ( @@ -79,6 +110,7 @@ class LockedProject(NamedTuple): name=bindparam("new_name"), data=bindparam("new_data"), minimum_canvas_schema_version=bindparam("new_minimum_canvas_schema_version"), + minimum_document_schema_version=bindparam("new_minimum_document_schema_version"), revision=projects.c.revision + 1, ) ) @@ -139,17 +171,27 @@ def _shown_by_the_gallery(category: ColumnElement[str]) -> ColumnElement[bool]: _BOARD_ITEMS = _ITEMS.limit(bindparam("limit")) +# The fields `_SUMMARY_COLUMNS` select, in their order. +_SUMMARY_FIELDS = ( + "project_id", + "board_id", + "name", + "revision", + "minimum_canvas_schema_version", + "minimum_document_schema_version", + "created_at", + "updated_at", + "workspace_updated_at", +) +assert len(_SUMMARY_FIELDS) == len(_SUMMARY_COLUMNS) + + +def _summary_fields(row: Sequence[Any]) -> dict[str, Any]: + return dict(zip(_SUMMARY_FIELDS, row[: len(_SUMMARY_FIELDS)], strict=True)) + + def _summary(row: Sequence[Any]) -> ProjectSummaryDTO: - project_id, board_id, name, revision, minimum_canvas_schema_version, created_at, updated_at = row - return ProjectSummaryDTO( - project_id=project_id, - board_id=board_id, - name=name, - revision=revision, - minimum_canvas_schema_version=minimum_canvas_schema_version, - created_at=created_at, - updated_at=updated_at, - ) + return ProjectSummaryDTO(**_summary_fields(row)) def _summary_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectSummaryDTO]: @@ -163,17 +205,14 @@ def _summaries(rows: Sequence[Sequence[Any]]) -> list[ProjectSummaryDTO]: def _record_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectRecordDTO]: if row is None: return None - project_id, board_id, name, revision, minimum_canvas_schema_version, created_at, updated_at, data = row - return ProjectRecordDTO( - project_id=project_id, - board_id=board_id, - name=name, - revision=revision, - minimum_canvas_schema_version=minimum_canvas_schema_version, - created_at=created_at, - updated_at=updated_at, - data=json.loads(data), - ) + return ProjectRecordDTO(**_summary_fields(row), data=json.loads(row[-1])) + + +def _workspace_or_none(row: Optional[Sequence[Any]]) -> Optional[ProjectWorkspaceDTO]: + if row is None: + return None + data, revision, updated_at = row + return ProjectWorkspaceDTO(data=json.loads(data), revision=revision, updated_at=updated_at) def _board_snapshot_or_none( @@ -223,8 +262,7 @@ def lock(self, conn: Connection, user_id: str, project_id: str) -> Optional[Lock row: Optional[Sequence[Any]] = conn.execute(_LOCK, {"user_id": user_id, "project_id": project_id}).first() if row is None: return None - revision, minimum_canvas_schema_version, board_id = row - return LockedProject(revision, minimum_canvas_schema_version, board_id) + return LockedProject(*row) @read def claimant(self, conn: Connection, board_id: str) -> Optional[tuple[str, str]]: @@ -261,6 +299,7 @@ def insert( data: str, board_id: str, minimum_canvas_schema_version: int, + minimum_document_schema_version: int, ) -> None: conn.execute( _INSERT, @@ -271,6 +310,7 @@ def insert( "data": data, "board_id": board_id, "minimum_canvas_schema_version": minimum_canvas_schema_version, + "minimum_document_schema_version": minimum_document_schema_version, }, ) @@ -284,6 +324,7 @@ def save( name: str, data: str, minimum_canvas_schema_version: int, + minimum_document_schema_version: int, ) -> None: """Writes the project as its next revision. The caller has locked it and checked the revision it saves over.""" conn.execute( @@ -294,9 +335,44 @@ def save( "new_name": name, "new_data": data, "new_minimum_canvas_schema_version": minimum_canvas_schema_version, + "new_minimum_document_schema_version": minimum_document_schema_version, }, ) + @mapped(_workspace_or_none) + @read + def workspace(self, conn: Connection, user_id: str, project_id: str) -> Optional[Row[Any]]: + """The account's workspace in the project, if it has saved one.""" + return conn.execute(_WORKSPACE, {"user_id": user_id, "project_id": project_id}).first() + + @read + def workspace_members(self, conn: Connection, project_id: str) -> list[str]: + """The accounts with a workspace in the project.""" + return list(conn.execute(_WORKSPACE_MEMBERS, {"project_id": project_id}).scalars().all()) + + @read + def workspaces_in_owned(self, conn: Connection, user_id: str) -> list[tuple[str, str]]: + """(member, project) of every workspace in the account's projects, the account's own among them.""" + rows: Sequence[Sequence[Any]] = conn.execute(_WORKSPACES_IN_OWNED, {"user_id": user_id}).all() + return [(row[0], row[1]) for row in rows] + + @read + def workspace_stamp(self, conn: Connection, user_id: str, project_id: str) -> Optional[tuple[int, str]]: + """The workspace's revision and when it was saved, without its document.""" + row: Optional[Sequence[Any]] = conn.execute( + _WORKSPACE_STAMP, {"user_id": user_id, "project_id": project_id} + ).first() + return (row[0], row[1]) if row is not None else None + + @write + def insert_workspace(self, conn: Connection, *, user_id: str, project_id: str, data: str) -> None: + conn.execute(_INSERT_WORKSPACE, {"project_id": project_id, "user_id": user_id, "data": data}) + + @write + def save_workspace(self, conn: Connection, *, user_id: str, project_id: str, data: str) -> None: + """Writes the workspace as its next revision. The caller has locked the project and checked the revision.""" + conn.execute(_SAVE_WORKSPACE, {"target_project_id": project_id, "target_user_id": user_id, "new_data": data}) + @write def delete(self, conn: Connection, user_id: str, project_id: str) -> None: conn.execute(_DELETE, {"user_id": user_id, "project_id": project_id}) diff --git a/invokeai/app/services/shared/media_references.py b/invokeai/app/services/shared/media_references.py index 5367a240615..9f4fb4d3527 100644 --- a/invokeai/app/services/shared/media_references.py +++ b/invokeai/app/services/shared/media_references.py @@ -20,7 +20,7 @@ from dataclasses import dataclass, field from typing import Literal -MediaReferenceOwnerKind = Literal["project", "workflow", "client_state", "quarantined_project"] +MediaReferenceOwnerKind = Literal["project", "project_workspace", "workflow", "client_state", "quarantined_project"] MediaKind = Literal["image", "video"] IMAGE_NAME_KEYS = frozenset({"imageName", "image_name"}) diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 77b1d24f063..755aeb306bd 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -154,13 +154,17 @@ def delete(self, user_id: str) -> None: def delete_user(q: Queries) -> None: q.locks.acquire(DatabaseLock.ADMIN_ACCOUNTS) self._assert_not_last_admin(q, user_id, is_deleting=True) + # Read first: the account's projects cascade with it, and every member's workspace in them. + workspaces = q.projects.workspaces_in_owned(user_id) q.users.delete(user_id) # The account's projects cascade with it; its boards stay, for admins, in no project. q.boards.release_owned(user_id) - # Projects and client state cascade with the account; the media references they held + # Projects, workspaces and client state cascade with the account; the media references they held # would otherwise protect a departed account's intermediates from every cleanup forever. # Workflows and quarantined projects do not cascade, so their references stay with them. - q.media_references.delete_owned_by(user_id, "project", "client_state") + q.media_references.delete_owned_by(user_id, "project", "project_workspace", "client_state") + for member, project_id in workspaces: + q.media_references.delete(owner_kind="project_workspace", user_id=member, owner_id=project_id) self._queries.run(delete_user) diff --git a/invokeai/frontend/api/openapi.json b/invokeai/frontend/api/openapi.json index fdcab1bb395..7ceac49e41c 100644 --- a/invokeai/frontend/api/openapi.json +++ b/invokeai/frontend/api/openapi.json @@ -15928,7 +15928,7 @@ "get": { "tags": ["projects"], "summary": "Get Project", - "description": "Gets one of the current user's projects, including its document.", + "description": "Gets one of the current user's projects: its content and the caller's workspace in it.", "operationId": "get_project", "security": [ { @@ -15959,6 +15959,19 @@ "title": "Max Canvas Schema Version" }, "description": "Newest canvas schema understood by this client" + }, + { + "name": "max_document_schema_version", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Newest project document schema understood by this client", + "default": 3, + "title": "Max Document Schema Version" + }, + "description": "Newest project document schema understood by this client" } ], "responses": { @@ -15967,7 +15980,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRecordDTO" + "$ref": "#/components/schemas/ProjectWithWorkspaceDTO" } } } @@ -16094,6 +16107,65 @@ } } }, + "/api/v1/projects/{project_id}/workspace": { + "put": { + "tags": ["projects"], + "summary": "Save Project Workspace", + "description": "Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart\nfrom the project content, so that saving one never conflicts with the other. Returns 409 with the current\nrevision (null when there is none) when the save is based on another.", + "operationId": "save_project_workspace", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project whose workspace to save", + "title": "Project Id" + }, + "description": "The id of the project whose workspace to save" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectWorkspaceSaveRequest", + "description": "The workspace and the revision it is based on" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectWorkspaceDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/projects/{project_id}/board-snapshot": { "get": { "tags": ["projects"], @@ -51611,9 +51683,9 @@ "properties": { "kind": { "type": "string", - "enum": ["project", "workflow", "client_state", "quarantined_project"], + "enum": ["project", "project_workspace", "workflow", "client_state", "quarantined_project"], "title": "Kind", - "description": "`client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" + "description": "`project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair" }, "user_id": { "type": "string", @@ -90816,6 +90888,20 @@ "title": "Max Canvas Schema Version", "description": "Newest canvas schema understood by this client", "default": 2 + }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema that can read this document", + "default": 3 + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client", + "default": 3 } }, "type": "object", @@ -90851,6 +90937,12 @@ "title": "Minimum Canvas Schema Version", "description": "Oldest canvas schema a client must support before it may read or write this project" }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema a client must support before it may read or write this project" + }, "created_at": { "type": "string", "title": "Created At", @@ -90859,13 +90951,25 @@ "updated_at": { "type": "string", "title": "Updated At", - "description": "When the project was last saved" + "description": "When the project content was last saved" + }, + "workspace_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Updated At", + "description": "When the caller last saved their workspace in the project; null if they have none" }, "data": { "additionalProperties": true, "type": "object", "title": "Data", - "description": "The opaque client-owned project document" + "description": "The opaque client-owned project content (document schema 4 on)" } }, "type": "object", @@ -90875,6 +90979,7 @@ "name", "revision", "minimum_canvas_schema_version", + "minimum_document_schema_version", "created_at", "updated_at", "data" @@ -90910,6 +91015,12 @@ "title": "Minimum Canvas Schema Version", "description": "Oldest canvas schema a client must support before it may read or write this project" }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema a client must support before it may read or write this project" + }, "created_at": { "type": "string", "title": "Created At", @@ -90918,7 +91029,19 @@ "updated_at": { "type": "string", "title": "Updated At", - "description": "When the project was last saved" + "description": "When the project content was last saved" + }, + "workspace_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Updated At", + "description": "When the caller last saved their workspace in the project; null if they have none" } }, "type": "object", @@ -90928,6 +91051,7 @@ "name", "revision", "minimum_canvas_schema_version", + "minimum_document_schema_version", "created_at", "updated_at" ], @@ -90971,6 +91095,26 @@ "title": "Max Canvas Schema Version", "description": "Newest canvas schema understood by this client", "default": 2 + }, + "minimum_document_schema_version": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Minimum Document Schema Version", + "description": "New document schema floor to store atomically with this content; omitted to keep the current floor" + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client", + "default": 3 } }, "type": "object", @@ -90978,6 +91122,152 @@ "title": "ProjectUpdateRequest", "description": "Request body for saving a project with optimistic concurrency." }, + "ProjectWithWorkspaceDTO": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "The project's client-generated identifier" + }, + "board_id": { + "type": "string", + "title": "Board Id", + "description": "The project's private board; only project APIs may rename or delete it" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save" + }, + "minimum_canvas_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Canvas Schema Version", + "description": "Oldest canvas schema a client must support before it may read or write this project" + }, + "minimum_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Minimum Document Schema Version", + "description": "Oldest project document schema a client must support before it may read or write this project" + }, + "created_at": { + "type": "string", + "title": "Created At", + "description": "When the project was created" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the project content was last saved" + }, + "workspace_updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workspace Updated At", + "description": "When the caller last saved their workspace in the project; null if they have none" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project content (document schema 4 on)" + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectWorkspaceDTO" + }, + { + "type": "null" + } + ], + "description": "The caller's workspace; null until they save one, when the client builds a default" + } + }, + "type": "object", + "required": [ + "project_id", + "board_id", + "name", + "revision", + "minimum_canvas_schema_version", + "minimum_document_schema_version", + "created_at", + "updated_at", + "data", + "workspace" + ], + "title": "ProjectWithWorkspaceDTO", + "description": "A project's content together with the caller's workspace in it." + }, + "ProjectWorkspaceDTO": { + "properties": { + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned workspace document" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save of this workspace" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the workspace was last saved" + } + }, + "type": "object", + "required": ["data", "revision", "updated_at"], + "title": "ProjectWorkspaceDTO", + "description": "One account's workspace in a project: how they work on it (layout, widgets, generation settings)." + }, + "ProjectWorkspaceSaveRequest": { + "properties": { + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned workspace document" + }, + "expected_revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expected Revision", + "description": "The workspace revision this save is based on, or null for the caller's first workspace in the project; a mismatch returns 409" + }, + "max_document_schema_version": { + "type": "integer", + "minimum": 1.0, + "title": "Max Document Schema Version", + "description": "Newest project document schema understood by this client" + } + }, + "type": "object", + "required": ["data", "expected_revision", "max_document_schema_version"], + "title": "ProjectWorkspaceSaveRequest", + "description": "Request body for saving the caller's workspace in a project with optimistic concurrency." + }, "PromptEnhancerField": { "description": "Field for the optional ERNIE-Image prompt-enhancer (Ministral3ForCausalLM).", "properties": { diff --git a/invokeai/frontend/api/schema.ts b/invokeai/frontend/api/schema.ts index cf4505f049a..eeff73c03d7 100644 --- a/invokeai/frontend/api/schema.ts +++ b/invokeai/frontend/api/schema.ts @@ -4061,7 +4061,7 @@ export type paths = { }; /** * Get Project - * @description Gets one of the current user's projects, including its document. + * @description Gets one of the current user's projects: its content and the caller's workspace in it. */ get: operations["get_project"]; /** @@ -4086,6 +4086,28 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/projects/{project_id}/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save Project Workspace + * @description Saves the caller's workspace in one of their projects: their layout, widgets and generation settings, apart + * from the project content, so that saving one never conflicts with the other. Returns 409 with the current + * revision (null when there is none) when the save is based on another. + */ + put: operations["save_project_workspace"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/projects/{project_id}/board-snapshot": { parameters: { query?: never; @@ -21316,10 +21338,10 @@ export type components = { IntermediatesAffectedDocument: { /** * Kind - * @description `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair + * @description `project_workspace` is one account's workspace in a project; `client_state` is the legacy editor's persisted state; `quarantined_project` is a project kept for repair * @enum {string} */ - kind: "project" | "workflow" | "client_state" | "quarantined_project"; + kind: "project" | "project_workspace" | "workflow" | "client_state" | "quarantined_project"; /** User Id */ user_id: string; /** @@ -38814,6 +38836,18 @@ export type components = { * @default 2 */ max_canvas_schema_version?: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema that can read this document + * @default 3 + */ + minimum_document_schema_version?: number; + /** + * Max Document Schema Version + * @description Newest project document schema understood by this client + * @default 3 + */ + max_document_schema_version?: number; }; /** * ProjectRecordDTO @@ -38845,6 +38879,11 @@ export type components = { * @description Oldest canvas schema a client must support before it may read or write this project */ minimum_canvas_schema_version: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema a client must support before it may read or write this project + */ + minimum_document_schema_version: number; /** * Created At * @description When the project was created @@ -38852,12 +38891,17 @@ export type components = { created_at: string; /** * Updated At - * @description When the project was last saved + * @description When the project content was last saved */ updated_at: string; + /** + * Workspace Updated At + * @description When the caller last saved their workspace in the project; null if they have none + */ + workspace_updated_at?: string | null; /** * Data - * @description The opaque client-owned project document + * @description The opaque client-owned project content (document schema 4 on) */ data: { [key: string]: unknown; @@ -38893,6 +38937,11 @@ export type components = { * @description Oldest canvas schema a client must support before it may read or write this project */ minimum_canvas_schema_version: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema a client must support before it may read or write this project + */ + minimum_document_schema_version: number; /** * Created At * @description When the project was created @@ -38900,9 +38949,14 @@ export type components = { created_at: string; /** * Updated At - * @description When the project was last saved + * @description When the project content was last saved */ updated_at: string; + /** + * Workspace Updated At + * @description When the caller last saved their workspace in the project; null if they have none + */ + workspace_updated_at?: string | null; }; /** * ProjectUpdateRequest @@ -38937,6 +38991,123 @@ export type components = { * @default 2 */ max_canvas_schema_version?: number; + /** + * Minimum Document Schema Version + * @description New document schema floor to store atomically with this content; omitted to keep the current floor + */ + minimum_document_schema_version?: number | null; + /** + * Max Document Schema Version + * @description Newest project document schema understood by this client + * @default 3 + */ + max_document_schema_version?: number; + }; + /** + * ProjectWithWorkspaceDTO + * @description A project's content together with the caller's workspace in it. + */ + ProjectWithWorkspaceDTO: { + /** + * Project Id + * @description The project's client-generated identifier + */ + project_id: string; + /** + * Board Id + * @description The project's private board; only project APIs may rename or delete it + */ + board_id: string; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Revision + * @description Monotonic revision, incremented on every save + */ + revision: number; + /** + * Minimum Canvas Schema Version + * @description Oldest canvas schema a client must support before it may read or write this project + */ + minimum_canvas_schema_version: number; + /** + * Minimum Document Schema Version + * @description Oldest project document schema a client must support before it may read or write this project + */ + minimum_document_schema_version: number; + /** + * Created At + * @description When the project was created + */ + created_at: string; + /** + * Updated At + * @description When the project content was last saved + */ + updated_at: string; + /** + * Workspace Updated At + * @description When the caller last saved their workspace in the project; null if they have none + */ + workspace_updated_at?: string | null; + /** + * Data + * @description The opaque client-owned project content (document schema 4 on) + */ + data: { + [key: string]: unknown; + }; + /** @description The caller's workspace; null until they save one, when the client builds a default */ + workspace: components["schemas"]["ProjectWorkspaceDTO"] | null; + }; + /** + * ProjectWorkspaceDTO + * @description One account's workspace in a project: how they work on it (layout, widgets, generation settings). + */ + ProjectWorkspaceDTO: { + /** + * Data + * @description The opaque client-owned workspace document + */ + data: { + [key: string]: unknown; + }; + /** + * Revision + * @description Monotonic revision, incremented on every save of this workspace + */ + revision: number; + /** + * Updated At + * @description When the workspace was last saved + */ + updated_at: string; + }; + /** + * ProjectWorkspaceSaveRequest + * @description Request body for saving the caller's workspace in a project with optimistic concurrency. + */ + ProjectWorkspaceSaveRequest: { + /** + * Data + * @description The opaque client-owned workspace document + */ + data: { + [key: string]: unknown; + }; + /** + * Expected Revision + * @description The workspace revision this save is based on, or null for the caller's first workspace in the project; a mismatch returns 409 + */ + expected_revision: number | null; + /** + * Max Document Schema Version + * @description Newest project document schema understood by this client + */ + max_document_schema_version: number; }; /** * PromptEnhancerField @@ -60232,6 +60403,8 @@ export interface operations { query?: { /** @description Newest canvas schema understood by this client */ max_canvas_schema_version?: number; + /** @description Newest project document schema understood by this client */ + max_document_schema_version?: number; }; header?: never; path: { @@ -60248,7 +60421,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProjectRecordDTO"]; + "application/json": components["schemas"]["ProjectWithWorkspaceDTO"]; }; }; /** @description Validation Error */ @@ -60331,6 +60504,42 @@ export interface operations { }; }; }; + save_project_workspace: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project whose workspace to save */ + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectWorkspaceSaveRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectWorkspaceDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_project_board_snapshot: { parameters: { query?: never; diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 9fd561d336a..817b39185fe 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4782,6 +4782,8 @@ "affected": "Documents that will lose references", "affectedProject_one": "Project “{{name}}” ({{count, number}} item)", "affectedProject_other": "Project “{{name}}” ({{count, number}} items)", + "affectedProjectWorkspace_one": "Workspace in project “{{name}}” ({{count, number}} item)", + "affectedProjectWorkspace_other": "Workspace in project “{{name}}” ({{count, number}} items)", "affectedWorkflow_one": "Workflow “{{name}}” ({{count, number}} item)", "affectedWorkflow_other": "Workflow “{{name}}” ({{count, number}} items)", "affectedOwner": "· {{owner}}", diff --git a/invokeai/frontend/webv2/src/features/intermediates/core/types.ts b/invokeai/frontend/webv2/src/features/intermediates/core/types.ts index f3d925e8e5b..b74bffbf43b 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/core/types.ts +++ b/invokeai/frontend/webv2/src/features/intermediates/core/types.ts @@ -95,10 +95,16 @@ export interface IntermediatesImpact { } /** - * What names the media: a saved project or library workflow, the legacy editor's persisted state (`ownerId` is its - * state key), or a project set aside for repair by an earlier migration. + * What names the media: a saved project, one account's workspace in a project (`ownerId` is the project's id), a + * library workflow, the legacy editor's persisted state (`ownerId` is its state key), or a project set aside for + * repair by an earlier migration. */ -export type IntermediatesAffectedDocumentKind = 'project' | 'workflow' | 'client_state' | 'quarantined_project'; +export type IntermediatesAffectedDocumentKind = + | 'project' + | 'project_workspace' + | 'workflow' + | 'client_state' + | 'quarantined_project'; export interface IntermediatesAffectedDocument { kind: IntermediatesAffectedDocumentKind; diff --git a/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx b/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx index bead710594d..f9ed4c8125d 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx +++ b/invokeai/frontend/webv2/src/features/intermediates/ui/ClearDialog.tsx @@ -17,6 +17,7 @@ const CONFIRM_WORD = 'DELETE'; const AFFECTED_DOCUMENT_LABELS = { client_state: 'intermediates.dialog.affectedClientState', project: 'intermediates.dialog.affectedProject', + project_workspace: 'intermediates.dialog.affectedProjectWorkspace', quarantined_project: 'intermediates.dialog.affectedQuarantinedProject', workflow: 'intermediates.dialog.affectedWorkflow', } as const satisfies Record; diff --git a/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx b/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx index 02e7bebd4b0..906c1c2246b 100644 --- a/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/intermediates/ui/IntermediatesManager.browser.test.tsx @@ -88,6 +88,7 @@ const forceAffectedDocuments: IntermediatesPreview['affectedDocuments'] = [ { ...documentOf('alice'), kind: 'project', name: 'Portraits', ownerId: 'p1', references: 2 }, { ...documentOf('alice'), kind: 'client_state', name: null, ownerId: 'canvas', references: 1 }, { ...documentOf('alice'), kind: 'quarantined_project', name: 'Old sketch', ownerId: 'q1', references: 1 }, + { ...documentOf('alice'), kind: 'project_workspace', name: 'Portraits', ownerId: 'p1', references: 3 }, { ...documentOf('bob'), kind: 'client_state', name: null, ownerId: 'canvas', references: 1 }, ]; @@ -531,6 +532,7 @@ describe('IntermediatesManager', () => { const { activeOperationStore } = await import('@features/intermediates/data/operationStore'); const dialog = await openForceDialogReadyToConfirm(); expect(dialog.textContent).toContain('intermediates.dialog.affectedQuarantinedProject(count=1,name=Old sketch)'); + expect(dialog.textContent).toContain('intermediates.dialog.affectedProjectWorkspace(count=3,name=Portraits)'); expect(dialog.textContent).toContain('intermediates.dialog.forceWarningOwn'); dependencies.startIntermediatesOperation.mockRejectedValueOnce(new ApiError('Server error', 500)); diff --git a/tests/app/api/test_project_write_limits.py b/tests/app/api/test_project_write_limits.py index b6336e21c74..ecb26f3eedc 100644 --- a/tests/app/api/test_project_write_limits.py +++ b/tests/app/api/test_project_write_limits.py @@ -1,6 +1,7 @@ import asyncio from typing import Any +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -25,6 +26,11 @@ async def update_project(project_id: str) -> dict[str, bool]: calls.append(f"update:{project_id}") return {"ok": True} + @app.put("/api/v1/projects/{project_id}/workspace") + async def save_workspace(project_id: str) -> dict[str, bool]: + calls.append(f"workspace:{project_id}") + return {"ok": True} + @app.post("/api/v1/projects/import") async def unrelated_project_route() -> dict[str, bool]: calls.append("import") @@ -56,10 +62,12 @@ def test_create_is_rejected_before_json_parsing_when_request_is_oversized() -> N assert calls == [] -def test_update_is_rejected_before_json_parsing_when_request_is_oversized() -> None: +@pytest.mark.parametrize("path", ["/api/v1/projects/project-1", "/api/v1/projects/project-1/workspace"]) +def test_update_is_rejected_before_json_parsing_when_request_is_oversized(path: str) -> None: + """A content save and a workspace save are each a document as large.""" client, calls = _client() - response = client.put("/api/v1/projects/project-1", content=b" " * (MAX_BODY + 1)) + response = client.put(path, content=b" " * (MAX_BODY + 1)) assert response.status_code == 413 assert calls == [] diff --git a/tests/app/routers/test_projects.py b/tests/app/routers/test_projects.py index 3f3bcc5b44c..b8448803817 100644 --- a/tests/app/routers/test_projects.py +++ b/tests/app/routers/test_projects.py @@ -457,3 +457,108 @@ def test_the_board_snapshot_lists_member_boards_after_the_inbox(client: TestClie (created["board_id"], True, "Project"), (member, False, "Member"), ] + + +# region workspaces + + +def _split_project(client: TestClient, token: str) -> dict[str, Any]: + response = _create_project( + client, token, data={"canvas": {}}, minimum_document_schema_version=4, max_document_schema_version=4 + ) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + +def _put_workspace(client: TestClient, token: str, project_id: str, expected: int | None, data: dict[str, Any]): + return client.put( + f"/api/v1/projects/{project_id}/workspace", + json={"data": data, "expected_revision": expected, "max_document_schema_version": 4}, + headers=_auth(token), + ) + + +def test_a_project_is_read_with_the_callers_workspace(client: TestClient, user1_token: str): + project = _split_project(client, user1_token) + saved = _put_workspace(client, user1_token, project["project_id"], None, {"layout": "mine"}) + + fetched = client.get( + f"/api/v1/projects/{project['project_id']}?max_document_schema_version=4", headers=_auth(user1_token) + ).json() + + assert saved.status_code == status.HTTP_200_OK + assert fetched["data"] == {"canvas": {}} + assert fetched["workspace"] == saved.json() + assert (saved.json()["revision"], saved.json()["data"]) == (1, {"layout": "mine"}) + + +def test_a_stale_workspace_save_is_a_409_with_the_current_revision(client: TestClient, user1_token: str): + project_id = _split_project(client, user1_token)["project_id"] + _put_workspace(client, user1_token, project_id, None, {"v": 1}) + + stale = _put_workspace(client, user1_token, project_id, None, {"v": "lost"}) + + assert stale.status_code == status.HTTP_409_CONFLICT + assert stale.json()["detail"]["current_revision"] == 1 + + +def test_a_client_that_predates_the_split_gets_a_412_naming_the_document_schema(client: TestClient, user1_token: str): + project_id = _split_project(client, user1_token)["project_id"] + + refused = client.get(f"/api/v1/projects/{project_id}", headers=_auth(user1_token)) + + assert refused.status_code == status.HTTP_412_PRECONDITION_FAILED + assert refused.json()["detail"]["code"] == "document_schema_unsupported" + assert ( + refused.json()["detail"]["minimum_document_schema_version"], + refused.json()["detail"]["max_document_schema_version"], + ) == (4, 3) + + +def test_a_save_from_a_client_that_predates_the_split_is_a_412(client: TestClient, user1_token: str): + """Both halves: the content save today's client sends, with no `max_document_schema_version`, and a workspace + save that declares too old a schema.""" + project_id = _split_project(client, user1_token)["project_id"] + + content = client.put( + f"/api/v1/projects/{project_id}", + json={"name": "Whole", "data": {"whole": True}, "expected_revision": 1}, + headers=_auth(user1_token), + ) + workspace = client.put( + f"/api/v1/projects/{project_id}/workspace", + json={"data": {}, "expected_revision": None, "max_document_schema_version": 3}, + headers=_auth(user1_token), + ) + + for refused in (content, workspace): + assert refused.status_code == status.HTTP_412_PRECONDITION_FAILED + assert refused.json()["detail"]["code"] == "document_schema_unsupported" + + +def test_lowering_the_document_schema_floor_is_a_400(client: TestClient, user1_token: str): + project_id = _split_project(client, user1_token)["project_id"] + + lowered = client.put( + f"/api/v1/projects/{project_id}", + json={ + "name": "Project", + "data": {}, + "expected_revision": 1, + "minimum_document_schema_version": 3, + "max_document_schema_version": 4, + }, + headers=_auth(user1_token), + ) + + assert lowered.status_code == status.HTTP_400_BAD_REQUEST + assert lowered.json()["detail"]["code"] == "document_schema_downgrade" + + +def test_no_one_else_can_save_a_workspace_in_a_project(client: TestClient, user1_token: str, user2_token: str): + project_id = _split_project(client, user1_token)["project_id"] + + assert _put_workspace(client, user2_token, project_id, None, {}).status_code == status.HTTP_404_NOT_FOUND + + +# endregion diff --git a/tests/app/services/intermediates/test_intermediates_service.py b/tests/app/services/intermediates/test_intermediates_service.py index 052764f8dee..e4fccfd9ddb 100644 --- a/tests/app/services/intermediates/test_intermediates_service.py +++ b/tests/app/services/intermediates/test_intermediates_service.py @@ -1565,6 +1565,25 @@ def test_quarantined_projects_are_named_when_a_force_clear_would_break_them( assert [(doc.kind, doc.name) for doc in force.affected_documents] == [("quarantined_project", "Before boards")] +def test_a_workspace_is_named_after_its_project_when_a_force_clear_would_break_it( + invoker: Invoker, service: IntermediatesService +) -> None: + _seed_image(invoker, "staged.png") + project = invoker.services.project_records.create( + "alice", "Poster", {}, minimum_document_schema_version=4, max_document_schema_version=4 + ) + invoker.services.project_records.save_workspace( + "alice", project.project_id, None, {"staged": {"imageName": "staged.png"}}, max_document_schema_version=4 + ) + + safe, _ = _run(service, ALICE, _owner("alice")) + assert safe.impact.keep_referenced_images == 1 + force = service.create_preview(IntermediatesPreviewRequest(mode="force", scope=_owner("alice")), ALICE) + assert [(doc.kind, doc.owner_id, doc.name) for doc in force.affected_documents] == [ + ("project_workspace", project.project_id, "Poster") + ] + + # ── bounds ── diff --git a/tests/app/services/project_records/test_project_records.py b/tests/app/services/project_records/test_project_records.py index e9c0161cd0e..ee339aeb92e 100644 --- a/tests/app/services/project_records/test_project_records.py +++ b/tests/app/services/project_records/test_project_records.py @@ -28,6 +28,7 @@ ProjectRecordConflictError, ProjectRecordExistsError, ProjectRecordNotFoundError, + ProjectWithWorkspaceDTO, ) from invokeai.app.services.project_records.project_records_default import ProjectRecordsStorage from invokeai.app.services.shared.database.database import Database @@ -70,7 +71,7 @@ def test_create_and_get_roundtrip(project_records: ProjectRecordsStorage) -> Non assert created.data == data fetched = project_records.get(SYSTEM_USER_ID, created.project_id) - assert fetched == created + assert fetched == ProjectWithWorkspaceDTO(**created.model_dump(), workspace=None) def test_oversized_documents_are_rejected_before_a_board_is_created( @@ -212,7 +213,9 @@ def test_update_increments_revision(project_records: ProjectRecordsStorage) -> N assert updated.revision == 2 assert updated.name == "Renamed" assert updated.data == {"v": 2} - assert project_records.get(SYSTEM_USER_ID, created.project_id) == updated + assert project_records.get(SYSTEM_USER_ID, created.project_id) == ProjectWithWorkspaceDTO( + **updated.model_dump(), workspace=None + ) def test_a_save_renews_when_the_project_was_updated(project_records: ProjectRecordsStorage, database: Database) -> None: @@ -693,6 +696,7 @@ def _replace_project(q: Queries, *, minimum_canvas_schema_version: int = 2) -> N data='{"new":true}', board_id="new-board", minimum_canvas_schema_version=minimum_canvas_schema_version, + minimum_document_schema_version=3, ) diff --git a/tests/app/services/project_records/test_project_workspaces.py b/tests/app/services/project_records/test_project_workspaces.py new file mode 100644 index 00000000000..a7547acb23f --- /dev/null +++ b/tests/app/services/project_records/test_project_workspaces.py @@ -0,0 +1,304 @@ +"""Each account's workspace in a project, saved apart from the project content, and the document schema floor that +keeps a client that predates the split from reading or writing half a project.""" + +from typing import Optional + +import pytest +from sqlalchemy import insert, select + +from invokeai.app.services.project_records.project_records_common import ( + ProjectDocumentSchemaDowngradeError, + ProjectDocumentSchemaUnsupportedError, + ProjectDocumentTooLargeError, + ProjectRecordNotFoundError, + ProjectWorkspaceConflictError, + ProjectWorkspaceDTO, +) +from invokeai.app.services.project_records.project_records_default import ProjectRecordsStorage +from invokeai.app.services.shared.database.database import Database +from invokeai.app.services.shared.database.schema.media_references import media_references +from invokeai.app.services.shared.database.schema.projects import project_workspaces +from invokeai.app.services.shared.database.schema.users import users +from tests.fixtures.database import capture_statements, explain_query_plan + +OWNER = "owner" +SPLIT = 4 + + +@pytest.fixture +def project_records(database: Database) -> ProjectRecordsStorage: + with database.begin(write=True) as conn: + conn.execute(insert(users).values(user_id=OWNER, email="owner@example.com", password_hash="-")) + return ProjectRecordsStorage(database) + + +def _split_project(project_records: ProjectRecordsStorage, content: Optional[dict] = None) -> str: + """A project as a client that writes document schema 4 creates it.""" + return project_records.create( + OWNER, + "Project", + content or {"canvas": {}}, + minimum_document_schema_version=SPLIT, + max_document_schema_version=SPLIT, + ).project_id + + +def _save( + project_records: ProjectRecordsStorage, project_id: str, expected: Optional[int], data: dict +) -> ProjectWorkspaceDTO: + return project_records.save_workspace(OWNER, project_id, expected, data, max_document_schema_version=SPLIT) + + +def test_content_and_workspace_are_saved_and_versioned_apart(project_records: ProjectRecordsStorage) -> None: + project_id = _split_project(project_records) + + first = _save(project_records, project_id, None, {"layout": "a"}) + content = project_records.update( + OWNER, + project_id, + expected_revision=1, + name="Renamed", + data={"canvas": {"v": 2}}, + max_document_schema_version=SPLIT, + ) + second = _save(project_records, project_id, first.revision, {"layout": "b"}) + + assert (first.revision, second.revision, content.revision) == (1, 2, 2) + fetched = project_records.get(OWNER, project_id, max_document_schema_version=SPLIT) + assert (fetched.revision, fetched.data) == (2, {"canvas": {"v": 2}}) + assert fetched.workspace is not None + assert (fetched.workspace.revision, fetched.workspace.data) == (2, {"layout": "b"}) + + +def test_a_project_without_a_workspace_reads_as_none(project_records: ProjectRecordsStorage) -> None: + project_id = _split_project(project_records) + + assert project_records.get(OWNER, project_id, max_document_schema_version=SPLIT).workspace is None + + +@pytest.mark.parametrize( + ("saved", "expected", "current"), + [ + ([], 1, None), # expects a workspace there is none of + ([{"v": 1}], None, 1), # a first save racing another tab's first save + ([{"v": 1}, {"v": 2}], 1, 2), # a stale save + ], +) +def test_a_workspace_save_on_another_revision_conflicts_and_writes_nothing( + project_records: ProjectRecordsStorage, saved: list[dict], expected: Optional[int], current: Optional[int] +) -> None: + project_id = _split_project(project_records) + revision = None + for data in saved: + revision = _save(project_records, project_id, revision, data).revision + + with pytest.raises(ProjectWorkspaceConflictError) as conflict: + _save(project_records, project_id, expected, {"v": "lost"}) + + assert conflict.value.current_revision == current + workspace = project_records.get(OWNER, project_id, max_document_schema_version=SPLIT).workspace + if saved: + assert workspace is not None + assert (workspace.revision, workspace.data) == (current, saved[-1]) + else: + assert workspace is None + + +def test_a_client_that_predates_the_split_is_refused_a_split_project(project_records: ProjectRecordsStorage) -> None: + """It would read the content as a whole document and save it back without the workspace.""" + project_id = _split_project(project_records) + + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.get(OWNER, project_id) + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.update(OWNER, project_id, expected_revision=1, name="Old", data={"whole": True}) + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.save_workspace(OWNER, project_id, None, {}, max_document_schema_version=3) + fetched = project_records.get(OWNER, project_id, max_document_schema_version=SPLIT) + assert (fetched.revision, fetched.workspace) == (1, None) + + +def test_no_save_raises_the_floor_past_what_its_client_understands(project_records: ProjectRecordsStorage) -> None: + """It would lock the client out of its own project.""" + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.create(OWNER, "Ahead", {}, minimum_document_schema_version=5, max_document_schema_version=4) + assert project_records.list(OWNER) == [] + + project_id = _split_project(project_records) + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.update( + OWNER, + project_id, + expected_revision=1, + name="Ahead", + data={}, + minimum_document_schema_version=5, + max_document_schema_version=4, + ) + assert project_records.list(OWNER)[0].minimum_document_schema_version == SPLIT + + +def test_a_client_below_the_floor_is_refused_before_its_revision_is_checked( + project_records: ProjectRecordsStorage, +) -> None: + """A stale old client must learn it needs updating, not that it has a conflict to resolve.""" + project_id = _split_project(project_records) + project_records.update( + OWNER, project_id, expected_revision=1, name="Moved on", data={}, max_document_schema_version=SPLIT + ) + + with pytest.raises(ProjectDocumentSchemaUnsupportedError): + project_records.update(OWNER, project_id, expected_revision=1, name="Stale", data={"whole": True}) + + +def test_the_first_split_save_raises_the_floor_which_never_falls(project_records: ProjectRecordsStorage) -> None: + project_id = project_records.create(OWNER, "Schema 3", {"whole": True}).project_id + assert project_records.list(OWNER)[0].minimum_document_schema_version == 3 + + raised = project_records.update( + OWNER, + project_id, + expected_revision=1, + name="Split", + data={"canvas": {}}, + minimum_document_schema_version=SPLIT, + max_document_schema_version=SPLIT, + ) + + assert raised.minimum_document_schema_version == SPLIT + with pytest.raises(ProjectDocumentSchemaDowngradeError): + project_records.update( + OWNER, + project_id, + expected_revision=2, + name="Split", + data={}, + minimum_document_schema_version=3, + max_document_schema_version=SPLIT, + ) + + +def test_only_the_owner_has_a_workspace_in_a_project( + project_records: ProjectRecordsStorage, database: Database +) -> None: + project_id = _split_project(project_records) + with database.begin(write=True) as conn: + conn.execute(insert(users).values(user_id="other", email="other@example.com", password_hash="-")) + + with pytest.raises(ProjectRecordNotFoundError): + project_records.save_workspace("other", project_id, None, {}, max_document_schema_version=SPLIT) + + +def test_a_workspace_has_the_same_size_limit_as_the_content( + project_records: ProjectRecordsStorage, monkeypatch: pytest.MonkeyPatch +) -> None: + from invokeai.app.services.project_records import project_records_default + + project_id = _split_project(project_records) + monkeypatch.setattr(project_records_default, "PROJECT_DOCUMENT_MAX_BYTES", 16) + + with pytest.raises(ProjectDocumentTooLargeError): + _save(project_records, project_id, None, {"layout": "x" * 32}) + assert project_records.get(OWNER, project_id, max_document_schema_version=SPLIT).workspace is None + + +def test_the_listing_says_when_each_half_was_last_saved(project_records: ProjectRecordsStorage) -> None: + project_id = _split_project(project_records) + (before,) = project_records.list(OWNER) + + workspace = _save(project_records, project_id, None, {}) + + (after,) = project_records.list(OWNER) + assert before.workspace_updated_at is None + assert after.workspace_updated_at == workspace.updated_at + assert after.updated_at == before.updated_at + + +def test_a_project_is_read_and_listed_with_the_callers_workspace_only( + project_records: ProjectRecordsStorage, database: Database +) -> None: + """Another account's workspace in the project is theirs: neither its data nor when they saved it.""" + project_id = _split_project(project_records) + with database.begin(write=True) as conn: + conn.execute(insert(users).values(user_id="member", email="member@example.com", password_hash="-")) + conn.execute(insert(project_workspaces).values(project_id=project_id, user_id="member", data='{"theirs":1}')) + + assert project_records.get(OWNER, project_id, max_document_schema_version=SPLIT).workspace is None + (listed,) = project_records.list(OWNER) + assert listed.workspace_updated_at is None + + mine = _save(project_records, project_id, None, {"mine": 1}) + + assert project_records.get(OWNER, project_id, max_document_schema_version=SPLIT).workspace == mine + (listed,) = project_records.list(OWNER) + assert listed.workspace_updated_at == mine.updated_at + + +# region media references + + +def _references(database: Database, owner_kind: str) -> set[str]: + with database.begin(write=False) as conn: + rows = conn.execute( + select(media_references.c.media_name).where(media_references.c.owner_kind == owner_kind) + ).all() + return {row[0] for row in rows} + + +def test_each_half_keeps_the_media_it_names(project_records: ProjectRecordsStorage, database: Database) -> None: + """A content save must not drop what only the workspace names, such as a staged candidate, or the intermediates + cleanup would delete it.""" + project_id = _split_project(project_records, {"layer": {"imageName": "layer.png"}}) + _save(project_records, project_id, None, {"staged": {"imageName": "staged.png"}}) + + project_records.update( + OWNER, + project_id, + expected_revision=1, + name="Project", + data={"layer": {"imageName": "edited.png"}}, + max_document_schema_version=SPLIT, + ) + + assert _references(database, "project") == {"edited.png"} + assert _references(database, "project_workspace") == {"staged.png"} + + _save(project_records, project_id, 1, {"staged": {"imageName": "restaged.png"}}) + + assert _references(database, "project") == {"edited.png"} + assert _references(database, "project_workspace") == {"restaged.png"} + + +def test_a_refused_workspace_save_leaves_its_references( + project_records: ProjectRecordsStorage, database: Database +) -> None: + project_id = _split_project(project_records) + _save(project_records, project_id, None, {"imageName": "kept.png"}) + + with pytest.raises(ProjectWorkspaceConflictError): + _save(project_records, project_id, None, {"imageName": "lost.png"}) + + assert _references(database, "project_workspace") == {"kept.png"} + + +def test_deleting_the_project_drops_the_workspace_and_its_references( + project_records: ProjectRecordsStorage, database: Database +) -> None: + project_id = _split_project(project_records) + _save(project_records, project_id, None, {"imageName": "a.png"}) + kept_id = _split_project(project_records) + _save(project_records, kept_id, None, {"imageName": "kept.png"}) + + with capture_statements(database) as captured: + project_records.delete(OWNER, project_id) + + assert _references(database, "project_workspace") == {"kept.png"} + # By key: not a scan of every workspace reference in the install, under the write lock. + deletes = [(sql, args) for sql, args in captured if sql.startswith("DELETE FROM media_references")] + assert deletes + for sql, args in deletes: + plan = " ".join(explain_query_plan(database, sql, args)) + assert "owner_kind=? AND user_id=? AND owner_id=?" in plan, plan + + +# endregion diff --git a/tests/app/services/users/test_user_service.py b/tests/app/services/users/test_user_service.py index c1f19f2edf7..5130899811a 100644 --- a/tests/app/services/users/test_user_service.py +++ b/tests/app/services/users/test_user_service.py @@ -11,6 +11,7 @@ from invokeai.app.services.shared.database.queries.base import IN_CHUNK from invokeai.app.services.shared.database.schema.boards import boards from invokeai.app.services.shared.database.schema.media_references import media_references +from invokeai.app.services.shared.database.schema.projects import project_workspaces, projects from invokeai.app.services.shared.database.schema.users import users as users_table from invokeai.app.services.users import users_default from invokeai.app.services.users.users_common import ( @@ -161,10 +162,32 @@ def test_delete_user(user_service: UserService): assert retrieved_user is None +def test_delete_user_drops_every_members_workspace_references_in_their_projects( + user_service: UserService, database: Database +): + """A member's workspace in a departing owner's project cascades with it; its references must not outlive it.""" + owner = user_service.create(UserCreateRequest(email="owner@example.com", password="TestPassword123")).user_id + member = user_service.create(UserCreateRequest(email="member@example.com", password="TestPassword123")).user_id + with database.begin(write=True) as conn: + conn.execute(insert(boards).values(board_id="inbox", board_name="P", user_id=owner)) + conn.execute(insert(projects).values(project_id="p", user_id=owner, name="P", data="{}", board_id="inbox")) + conn.execute(insert(project_workspaces).values(project_id="p", user_id=member, data="{}")) + conn.execute( + insert(media_references).values( + owner_kind="project_workspace", user_id=member, owner_id="p", media_kind="image", media_name="a.png" + ) + ) + + user_service.delete(owner) + + with database.begin(write=False) as conn: + assert conn.execute(select(media_references.c.owner_kind)).all() == [] + + def test_delete_user_drops_references_only_of_documents_that_cascade(user_service: UserService, database: Database): leaving = user_service.create(UserCreateRequest(email="leaving@example.com", password="TestPassword123")).user_id staying = user_service.create(UserCreateRequest(email="staying@example.com", password="TestPassword123")).user_id - kinds = ("project", "client_state", "workflow", "quarantined_project") + kinds = ("project", "project_workspace", "client_state", "workflow", "quarantined_project") with database.begin(write=True) as conn: conn.execute( insert(media_references), From b50b8c048121a3932656bba26b2b92221ebad3f4 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 14:20:05 -0600 Subject: [PATCH 05/16] feat(workflows): give new canvas and workflow items unique ids Layers, reference images, adjustments and workflow items take UUID-based ids from createItemId instead of a timestamp with a short random suffix. --- .../src/features/workflow/core/document.test.ts | 8 ++++++++ .../webv2/src/features/workflow/core/document.ts | 5 +++-- .../webv2/src/platform/browser/randomUuid.test.ts | 12 +++++++++++- .../webv2/src/platform/browser/randomUuid.ts | 3 +++ .../webv2/src/workbench/canvas-engine/engine.ts | 3 ++- .../frontend/webv2/src/workbench/canvasMigration.ts | 6 ++---- .../src/workbench/widgets/layers/layerOps.test.ts | 6 ++++++ .../webv2/src/workbench/widgets/layers/layerOps.ts | 9 ++++----- 8 files changed, 39 insertions(+), 13 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts index 39a84b79e48..004cb723007 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.test.ts @@ -81,6 +81,14 @@ const createEdge = (source: string, target: string): WorkflowEdge => ({ type: 'default', }); +describe('workflow item ids', () => { + it('are UUIDs, so a lock or presence marker can name a node whoever created it', () => { + expect(buildInvocationNode(template, { x: 0, y: 0 }).id).toMatch( + /^add-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); +}); + describe('projectGraphReducer', () => { it('builds nodes from templates with default input values', () => { const node = buildInvocationNode(template, { x: 5, y: 6 }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 76b40f9524b..199dcd4041f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -1,5 +1,7 @@ import type { SeedMode } from '@platform/core/seed'; +import { createItemId } from '@platform/browser/randomUuid'; + import type { ContainerFormElement, FieldIdentifier, @@ -35,8 +37,7 @@ import { isInvocationNode, isNotesNode } from './types'; const now = (): string => new Date().toISOString(); -export const createWorkflowId = (prefix: string): string => - `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +export const createWorkflowId = (prefix: string): string => createItemId(prefix); export const createWorkflowForm = (): WorkflowForm => { const rootElementId = createWorkflowId('container'); diff --git a/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts b/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts index 6e2a6e053f4..bf853fbc842 100644 --- a/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts +++ b/invokeai/frontend/webv2/src/platform/browser/randomUuid.test.ts @@ -1,4 +1,4 @@ -import { createUuid, createUuidWith, type UuidCryptoSource } from '@platform/browser/randomUuid'; +import { createItemId, createUuid, createUuidWith, type UuidCryptoSource } from '@platform/browser/randomUuid'; import { describe, expect, it, vi } from 'vitest'; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; @@ -68,3 +68,13 @@ describe('createUuid', () => { } }); }); + +describe('createItemId', () => { + it('keeps the prefix and adds a fresh UUID', () => { + const [first, second] = [createItemId('layer'), createItemId('layer')]; + + expect(first.slice(0, 'layer-'.length)).toBe('layer-'); + expect(first.slice('layer-'.length)).toMatch(UUID_V4); + expect(first).not.toBe(second); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts b/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts index 6b58b6dd321..406de9c9885 100644 --- a/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts +++ b/invokeai/frontend/webv2/src/platform/browser/randomUuid.ts @@ -22,3 +22,6 @@ export const createUuidWith = (source: UuidCryptoSource): string => { }; export const createUuid = (): string => createUuidWith(globalThis.crypto); + +/** `-`: an id unique without coordination, whichever client mints it. */ +export const createItemId = (prefix: string): string => `${prefix}-${createUuid()}`; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index fc80ab21760..6aceac3eaa0 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -91,6 +91,7 @@ import type { LayerTransform } from '@workbench/canvas-engine/transform/transfor import type { Rect, RenderFlags, ToolId, Vec2 } from '@workbench/canvas-engine/types'; import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import { createItemId } from '@platform/browser/randomUuid'; import { areJsonValuesStructurallyEqual } from '@platform/core/json'; import { PixelEditController } from '@workbench/canvas-engine/controllers/controlPixelController'; import { EditingController } from '@workbench/canvas-engine/controllers/editingController'; @@ -315,7 +316,7 @@ export interface CanvasEngineCoreComposition { } /** Mints a fresh layer id for engine-created paint layers. */ -const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const createLayerId = (): string => createItemId('layer'); const createEventId = (): string => `event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const clearSurface = (surface: RasterSurface): void => { diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts index d6ee177e59f..9d91441f0c5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts @@ -12,6 +12,7 @@ import type { LayerStackKind, } from '@workbench/canvas-engine/api'; +import { createItemId } from '@platform/browser/randomUuid'; import { CANVAS_MAX_NODE_COUNT, CANVAS_MAX_NODE_DEPTH, LAYER_STACK_ORDER } from '@workbench/canvas-engine/api'; import { repairSelectedLayerId } from '@workbench/canvas-engine/document/selectionRepair'; import { z } from 'zod'; @@ -24,9 +25,6 @@ import { normalizeControlAdapter } from './controlAdapters'; export const DEFAULT_CANVAS_DOCUMENT_WIDTH = 1024; export const DEFAULT_CANVAS_DOCUMENT_HEIGHT = 1024; -const createMigrationId = (prefix: string): string => - `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -320,7 +318,7 @@ export const createEmptyCanvasDocument = ( /** Mirror the default inpaint mask without importing the Layers panel into this pure module. */ const createInitialInpaintMaskLayer = (): CanvasInpaintMaskLayerContract => ({ blendMode: 'normal', - id: createMigrationId('layer'), + id: createItemId('layer'), isEnabled: true, isLocked: false, mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.test.ts index 9dc55151f8b..e612eaf41b8 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.test.ts @@ -97,6 +97,12 @@ describe('createEmptyPaintLayer', () => { it('mints a unique id when none is supplied', () => { expect(createEmptyPaintLayer('a').id).not.toBe(createEmptyPaintLayer('a').id); }); + + it('mints a UUID id, so a lock or presence marker can name the layer whoever created it', () => { + expect(createEmptyPaintLayer('a').id).toMatch( + /^layer-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ); + }); }); describe('nextRegionalGuidanceFillColor', () => { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.ts index 1b67b481158..6ece605deef 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.ts @@ -19,6 +19,7 @@ import type { export type { CanvasStructuralEngine } from '@workbench/canvas-engine/api'; import { getRegionalGuidanceSupport } from '@features/generation/graph'; +import { createItemId } from '@platform/browser/randomUuid'; import { getSourceContentRect, isMergeableRasterLayer, mergeDownEligibility } from '@workbench/canvas-engine/api'; import { CONTROL_ADAPTER_DEFAULTS, createDefaultControlAdapter } from '@workbench/controlAdapters'; @@ -59,7 +60,7 @@ export const CANVAS_BLEND_MODES: readonly CanvasBlendMode[] = [ ]; /** Mints a fresh layer id (matches the engine's `createLayerId` shape). */ -export const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +export const createLayerId = (): string => createItemId('layer'); /** Builds an empty, document-sized paint layer with sensible defaults. */ export const createEmptyPaintLayer = (name: string, id: string = createLayerId()): CanvasRasterLayerContractV2 => ({ @@ -217,12 +218,10 @@ export const createRegionalGuidanceFromImage = ( }); /** Mints a fresh regional-guidance reference-image id. */ -export const createReferenceImageId = (): string => - `rgref-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +export const createReferenceImageId = (): string => createItemId('rgref'); /** Mints a fresh raster adjustment-entry id. */ -export const createAdjustmentId = (): string => - `adj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +export const createAdjustmentId = (): string => createItemId('adj'); /** A fresh adjustment entry of `type` at its identity values (invert has none). */ export const createIdentityAdjustment = (type: CanvasAdjustmentEntry['type']): CanvasAdjustmentEntry => { From 0ebac6c47e9adb0c1d3f5b0d9b52fdd405371dfd Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 14:20:22 -0600 Subject: [PATCH 06/16] feat(projects): split each project into shared content and the member's workspace Saves send the content (raising the document floor to 4) and then the caller's workspace, each at its own revision, and loads join the two halves back into one document. Drafts, recovery, conflicts, copies and duplicates track both halves, and the conflict banner names a settings-only conflict. --- .../development/Front End/project-files.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 3 + .../frontend/webv2/scripts/mock-backend.mjs | 108 +- .../scripts/run-project-file-journey.mjs | 32 +- .../WorkbenchContext.browser.test.tsx | 58 +- .../webv2/src/workbench/projects/README.md | 12 +- .../webv2/src/workbench/projects/api.test.ts | 106 +- .../webv2/src/workbench/projects/api.ts | 108 +- .../projects/components/OpenProjectDialog.tsx | 4 +- .../projects/documentSchemaVersion.ts | 5 + .../workbench/projects/draftStore.contract.ts | 2 +- .../src/workbench/projects/draftStore.ts | 75 +- .../projects/durableSyncedPersistence.test.ts | 904 ++++++++++++++- .../projects/durableSyncedPersistence.ts | 1009 ++++++++++++----- .../workbench/projects/indexedDbDraftStore.ts | 46 +- .../projects/invk/duplicateProject.test.ts | 92 +- .../projects/invk/duplicateProject.ts | 51 +- .../src/workbench/projects/library.test.ts | 39 + .../webv2/src/workbench/projects/library.ts | 19 +- .../src/workbench/projects/projectFile.ts | 12 +- .../workbench/projects/projectHalves.test.ts | 183 +++ .../src/workbench/projects/projectHalves.ts | 228 ++++ .../workbench/projects/projectHydration.ts | 62 +- .../workbench/projects/projectSync.test.ts | 38 +- .../webv2/src/workbench/projects/syncStore.ts | 8 +- .../projects/unloadJournal.browser.test.ts | 3 + .../projects/unloadJournal.scenarios.ts | 120 +- .../projects/useProjectFileActions.ts | 6 +- .../ProjectConflictBanner.browser.test.tsx | 26 +- .../workbench/shell/ProjectConflictBanner.tsx | 20 +- 30 files changed, 2919 insertions(+), 462 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts diff --git a/docs/src/content/docs/development/Front End/project-files.mdx b/docs/src/content/docs/development/Front End/project-files.mdx index 68a9d0f36da..8e1b93c27dd 100644 --- a/docs/src/content/docs/development/Front End/project-files.mdx +++ b/docs/src/content/docs/development/Front End/project-files.mdx @@ -68,7 +68,7 @@ cannot drift from actual parsing support. ## Project content and workspaces -From document schema 4 a project is two documents. The server stores and serves both halves; webv2 still saves whole schema 3 documents through `PUT /projects/{id}` until it adopts the split, and in 7.0 only a project's owner can save a workspace in it (anyone else gets `404`). Its **content** is what the project is: the shared work a collaborator would review, export or edit. Each member's **workspace** is how they work on it: layout, widgets and their generation settings, and their selection and staging. The content stays in `projects.data`; each workspace is a row of `project_workspaces`, keyed by project and account, which deleting either removes. +From document schema 4 a project is two documents. Its **content** is what the project is: the shared work a collaborator would review, export or edit. Each member's **workspace** is how they work on it: layout, widgets and their generation settings, and their selection and staging. The content stays in `projects.data`; each workspace is a row of `project_workspaces`, keyed by project and account, which deleting either removes. In 7.0 only a project's owner can save a workspace in it (anyone else gets `404`). The web client keeps one whole document per project and splits it at the server boundary (see `src/workbench/projects/README.md`): it saves the content first, raising the floor, then the workspace. Imports still create a project whole, and its first save from an editor splits it. A project whose workflow repeats an item id, which only a build before the split could write, is created and kept whole, since split content that repeats one is refused on load. - `GET /projects/{id}` returns the content and, as `workspace`, the caller's workspace with its own `revision` (or `null` before they have saved one). - `PUT /projects/{id}` saves the content, revision-checked as before. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 817b39185fe..b23948f6cfb 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2083,6 +2083,9 @@ "export": "Export local draft", "revisionDescription": "The server and this tab contain different edits. You can keep both versions or replace this tab with the server version.", "revisionTitle": "Project changed elsewhere", + "workspaceRevisionDescription": "Your layout, prompts and generation settings for this project were saved from another tab or device. You can keep both versions, or use the server's settings and keep this tab's edits to the project.", + "workspaceRevisionTitle": "Your settings for this project changed elsewhere", + "workspaceUseServerConfirmBody": "Replace this tab's layout, prompts and generation settings with the server version? Your edits to the project itself are kept.", "saveAsNew": "Save as new project", "saveAsNewConfirmBody": "Create a separate project from the local version? Editing is paused until the copy is safely created.", "schemaDescription": "This project needs a newer version of InvokeAI. Export it before discarding the local draft.", diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 74836005032..859d2b76e4a 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -16,18 +16,43 @@ import { const DEFAULT_CANVAS_SCHEMA_VERSION = 3; /** The 412 the real router raises when a client's maximum is below a project's floor. */ -const schemaUnsupported = (minimum, maximum) => - json(412, { +const schemaUnsupported = (minimum, maximum) => [ + 412, + { detail: { code: 'canvas_schema_unsupported', max_canvas_schema_version: maximum, message: `Project requires canvas schema ${minimum}; client supports up to ${maximum}.`, minimum_canvas_schema_version: minimum, }, - }); + }, +]; const clientMaximum = (value) => (Number.isInteger(value) && value >= 1 ? value : DEFAULT_CANVAS_SCHEMA_VERSION); +/** A project saved whole; from 4 the record holds its content and each member's workspace apart. */ +const DEFAULT_DOCUMENT_SCHEMA_VERSION = 3; + +const documentSchemaUnsupported = (minimum, maximum) => [ + 412, + { + detail: { + code: 'document_schema_unsupported', + max_document_schema_version: maximum, + message: `Project requires document schema ${minimum}; client supports up to ${maximum}.`, + minimum_document_schema_version: minimum, + }, + }, +]; + +const documentMaximum = (value) => (Number.isInteger(value) && value >= 1 ? value : DEFAULT_DOCUMENT_SCHEMA_VERSION); + +/** A write's answer: the content, never the caller's workspace, as the real server returns it. */ +const writtenRecordOf = ({ workspace, ...project }) => ({ + ...project, + workspace_updated_at: workspace?.updated_at ?? null, +}); + /** In-memory test backend; no Socket.IO. POST /__reset restores the startup profile unless ?profile= overrides it. */ const FIXED_EPOCH_MS = Date.parse(MOCK_BACKEND_FIXED_EPOCH); @@ -202,10 +227,12 @@ const summaryOf = (project) => ({ board_id: project.board_id, created_at: project.created_at, minimum_canvas_schema_version: project.minimum_canvas_schema_version ?? DEFAULT_CANVAS_SCHEMA_VERSION, + minimum_document_schema_version: project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION, name: project.name, project_id: project.project_id, revision: project.revision, updated_at: project.updated_at, + workspace_updated_at: project.workspace?.updated_at ?? null, }); const readBody = (request) => @@ -967,7 +994,13 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const minimum = requested.minimum_canvas_schema_version ?? DEFAULT_CANVAS_SCHEMA_VERSION; const maximum = clientMaximum(requested.max_canvas_schema_version); if (minimum > maximum) { - return schemaUnsupported(minimum, maximum); + return json(...schemaUnsupported(minimum, maximum)); + } + const documentFloor = requested.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (documentFloor > documentMaximum(requested.max_document_schema_version)) { + return json( + ...documentSchemaUnsupported(documentFloor, documentMaximum(requested.max_document_schema_version)) + ); } const project = { @@ -975,16 +1008,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { created_at: now, data: requested.data ?? {}, minimum_canvas_schema_version: minimum, + minimum_document_schema_version: documentFloor, name, project_id: projectId, revision: 1, updated_at: now, + workspace: null, }; state.nextProjectNumber += 1; state.projects.set(project.project_id, project); - return json(200, project); + return json(200, writtenRecordOf(project)); } } @@ -1000,9 +1035,14 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { const maximum = clientMaximum( Number(url.searchParams.get('max_canvas_schema_version') ?? DEFAULT_CANVAS_SCHEMA_VERSION) ); - return project.minimum_canvas_schema_version > maximum - ? schemaUnsupported(project.minimum_canvas_schema_version, maximum) - : json(200, project); + const documentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + const documentClient = documentMaximum(Number(url.searchParams.get('max_document_schema_version'))); + if (project.minimum_canvas_schema_version > maximum) { + return json(...schemaUnsupported(project.minimum_canvas_schema_version, maximum)); + } + return documentFloor > documentClient + ? json(...documentSchemaUnsupported(documentFloor, documentClient)) + : json(200, { ...writtenRecordOf(project), workspace: project.workspace ?? null }); } if (method === 'PUT') { if (!project) { @@ -1016,8 +1056,28 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } const maximum = clientMaximum(requested.max_canvas_schema_version); if (project.minimum_canvas_schema_version > maximum) { - return schemaUnsupported(project.minimum_canvas_schema_version, maximum); + return json(...schemaUnsupported(project.minimum_canvas_schema_version, maximum)); } + const documentClient = documentMaximum(requested.max_document_schema_version); + const currentDocumentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (currentDocumentFloor > documentClient) { + return json(...documentSchemaUnsupported(currentDocumentFloor, documentClient)); + } + const requestedDocumentFloor = requested.minimum_document_schema_version ?? currentDocumentFloor; + if (requestedDocumentFloor < currentDocumentFloor) { + return json(400, { + detail: { + code: 'document_schema_downgrade', + current_minimum_document_schema_version: currentDocumentFloor, + message: 'A project never lowers its document schema floor.', + requested_minimum_document_schema_version: requestedDocumentFloor, + }, + }); + } + if (requestedDocumentFloor > documentClient) { + return json(...documentSchemaUnsupported(requestedDocumentFloor, documentClient)); + } + const documentFloor = requestedDocumentFloor; const requestedMinimum = requested.minimum_canvas_schema_version; if (requestedMinimum !== undefined && requestedMinimum < project.minimum_canvas_schema_version) { return json(400, { @@ -1030,12 +1090,13 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { }); } if (requestedMinimum !== undefined && requestedMinimum > maximum) { - return schemaUnsupported(requestedMinimum, maximum); + return json(...schemaUnsupported(requestedMinimum, maximum)); } project.data = requested.data ?? project.data; project.name = requested.name ?? project.name; project.minimum_canvas_schema_version = requestedMinimum ?? project.minimum_canvas_schema_version; + project.minimum_document_schema_version = documentFloor; project.revision += 1; project.updated_at = timestamp(state); @@ -1046,7 +1107,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { board.updated_at = project.updated_at; } - return json(200, project); + return json(200, writtenRecordOf(project)); } if (method === 'DELETE') { if (project) { @@ -1085,6 +1146,31 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { } } + const workspaceMatch = /^\/api\/v1\/projects\/([^/]+)\/workspace$/.exec(path); + if (method === 'PUT' && workspaceMatch) { + const project = state.projects.get(decodeURIComponent(workspaceMatch[1])); + if (!project) { + return json(404, { detail: 'Project not found' }); + } + const requested = await readJsonBody(request); + const documentFloor = project.minimum_document_schema_version ?? DEFAULT_DOCUMENT_SCHEMA_VERSION; + if (documentFloor > documentMaximum(requested.max_document_schema_version)) { + return json( + ...documentSchemaUnsupported(documentFloor, documentMaximum(requested.max_document_schema_version)) + ); + } + const current = project.workspace?.revision ?? null; + if (current !== (requested.expected_revision ?? null)) { + return json(409, { detail: { current_revision: current, message: 'Workspace revision conflict' } }); + } + project.workspace = { + data: requested.data ?? {}, + revision: (current ?? 0) + 1, + updated_at: timestamp(state), + }; + return json(200, project.workspace); + } + const boardSnapshotMatch = /^\/api\/v1\/projects\/([^/]+)\/board-snapshot$/.exec(path); if (method === 'GET' && boardSnapshotMatch) { const project = state.projects.get(decodeURIComponent(boardSnapshotMatch[1])); diff --git a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs index db10cf41950..211ef0f9ae5 100644 --- a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs +++ b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs @@ -199,6 +199,11 @@ const writeArchiveWithout = async (entries, omitted, targetPath) => { }; const getBoardSnapshot = (projectId) => fetchJson(`/api/v1/projects/${encodeURIComponent(projectId)}/board-snapshot`); +/** A project as this build reads it: content and the caller's workspace. */ +const getProjectRecord = (projectId) => + fetchJson( + `/api/v1/projects/${encodeURIComponent(projectId)}?max_canvas_schema_version=4&max_document_schema_version=4` + ); const getLayerImageNames = (project) => collectCanvasLeaves(project.data.canvas.document).map((layer) => layer.source.image.imageName); @@ -235,7 +240,7 @@ const waitForSavedWorkflows = async (projectId, predicate, label) => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { - const record = await fetchJson(`/api/v1/projects/${encodeURIComponent(projectId)}`); + const record = await getProjectRecord(projectId); if (predicate(record)) { return record; @@ -385,7 +390,12 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory await importPage.getByRole('main', { exact: true, name: summary.name }).waitFor(); assert.equal(new URL(importPage.url()).hash, '#/app'); - const imported = await fetchJson(`/api/v1/projects/${encodeURIComponent(summary.project_id)}`); + // Read between the split's content and workspace saves, the record would hold content with no workspace yet. + const imported = await waitForSavedWorkflows( + summary.project_id, + (record) => record.data.documentSchemaVersion !== 4 || record.workspace !== null, + 'the imported project whole, or split with its workspace' + ); const importedBoard = await getBoardSnapshot(summary.project_id); const importedBoardNames = snapshotItems(importedBoard).map((item) => item.name); @@ -455,10 +465,15 @@ const runRoundTrip = async ({ backend, browser, contexts, errors, tempDirectory assertNoBrowserErrors(errors); // The fixture was a schema-2 document with one `projectGraph`; import canonicalizes it into the current - // schema with that graph as the only workflow and no library write target. - assert.equal(imported.data.documentSchemaVersion, 3); + // schema with that graph as the only workflow and no library write target. Import creates the project whole and + // the editor splits it with its first save, which may already have happened: either shape holds the same. + assert.ok([3, 4].includes(imported.data.documentSchemaVersion), `schema ${imported.data.documentSchemaVersion}`); + const importedActiveWorkflowId = + imported.data.documentSchemaVersion === 4 + ? imported.workspace?.data.workflows.activeWorkflowId + : imported.data.workflows.activeWorkflowId; assert.equal(imported.data.workflows.entries.length, 1); - assert.equal(imported.data.workflows.activeWorkflowId, imported.data.workflows.entries[0].document.id); + assert.equal(importedActiveWorkflowId, imported.data.workflows.entries[0].document.id); assert.equal(imported.data.workflows.entries[0].source, undefined); await runDuplication({ browser, contexts, errors, imported, importedBoardNames }); @@ -524,7 +539,7 @@ const runDuplication = async ({ browser, contexts, errors, imported, importedBoa assert.equal(importedBoardNames.includes(name), false, `${name} is shared with the project it was copied from.`); } - const copiedRecord = await fetchJson(`/api/v1/projects/${encodeURIComponent(copy.project_id)}`); + const copiedRecord = await getProjectRecord(copy.project_id); const copiedLayers = getLayerImageNames(copiedRecord); const importedLayers = getLayerImageNames(imported); @@ -681,13 +696,14 @@ const runWorkflowCollection = async ({ browser, contexts, errors, imported }) => 'two workflows with the saved copy linked to its new template' ); - assert.equal(record.data.documentSchemaVersion, 3); + // Saved split: the content holds the workflows, and the workspace which one this account has open. + assert.equal(record.data.documentSchemaVersion, 4); assert.deepEqual( record.data.workflows.entries.map((entry) => entry.document.name), ['Empty Workflow', 'Journey Saved'] ); assert.deepEqual(record.data.workflows.entries[1].source, { libraryWorkflowId: saved.workflow_id, revision: 1 }); - assert.equal(record.data.workflows.activeWorkflowId, record.data.workflows.entries[1].document.id); + assert.equal(record.workspace.data.workflows.activeWorkflowId, record.data.workflows.entries[1].document.id); // Restart: both workflows and the active selection come back from the server. await page.reload({ waitUntil: 'domcontentloaded' }); diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx index a0fb73eed1a..b744826b313 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.browser.test.tsx @@ -1,6 +1,6 @@ import type { GenerationUiAdapter } from '@features/generation/react'; import type * as projectsApi from '@workbench/projects/api'; -import type { ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; import { ChakraProvider } from '@chakra-ui/react'; @@ -21,7 +21,7 @@ const server = vi.hoisted(() => ({ clientState: new Map(), /** Writes fail as they do for requests an unloading page starts; reads still answer the next page. */ offline: false, - records: new Map(), + records: new Map(), updateGate: null as Promise | null, })); @@ -39,15 +39,18 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { return unreachable(); } const id = request.project_id!; - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${id}`, created_at: '2026-10-04T00:00:00.000Z', data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: id, revision: 1, updated_at: '2026-10-04T00:00:00.000Z', + workspace_updated_at: null, + workspace: null, }; server.calls.push(`create:${request.name}`); server.records.set(id, record); @@ -68,6 +71,25 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { [...server.records.values()].map(({ data: _data, ...summary }) => structuredClone(summary)) ); }, + saveProjectWorkspace: (projectId: string, request: projectsApi.ProjectWorkspaceSaveRequest) => { + if (server.offline) { + return unreachable(); + } + const current = server.records.get(projectId); + if (!current) { + return Promise.reject(notFound()); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + return Promise.reject(new ApiError('conflict', 409)); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: '2026-10-04T00:00:00.000Z', + }; + server.records.set(projectId, { ...current, workspace, workspace_updated_at: workspace.updated_at }); + return Promise.resolve(structuredClone(workspace)); + }, setClientStateValue: (key: string, value: string) => { if (server.offline) { return unreachable(); @@ -88,7 +110,7 @@ vi.mock('@workbench/projects/api', async (importOriginal) => { if (current.revision !== request.expected_revision) { throw new ApiError('conflict', 409); } - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { ...current, data: structuredClone(request.data), name: request.name, @@ -315,7 +337,9 @@ describe('WorkbenchProvider hidden page', () => { await leaveEditor(); await mountEditor({ withStrength: true }); await vi.waitFor(() => expect(strengthSlider()).not.toBeNull(), { timeout: 5_000 }); - const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.data); + const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.workspace?.data); + // The workspace was saved with the project, so the checks below compare against a real copy. + expect(savedBeforeEdit).toEqual(expect.any(String)); const slider = strengthSlider()!; await act(() => { @@ -330,9 +354,12 @@ describe('WorkbenchProvider hidden page', () => { expect(document.activeElement).toBe(slider); expect(strengthValue()).toBeCloseTo(typedStrength); // Well before the 250 ms draft debounce and 500 ms autosave could have saved it on their own. - await vi.waitFor(() => expect(JSON.stringify(server.records.get(projectId)?.data)).not.toBe(savedBeforeEdit), { - timeout: 600, - }); + await vi.waitFor( + () => expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).not.toBe(savedBeforeEdit), + { + timeout: 600, + } + ); // The page goes away: its lifetime ends without the editor's exit checkpoint, and a new one loads it again. const { storageSuffix } = accountLifecycle.capture(); @@ -351,7 +378,9 @@ describe('WorkbenchProvider hidden page', () => { await leaveEditor(); await mountEditor({ withStrength: true }); await vi.waitFor(() => expect(strengthSlider()).not.toBeNull(), { timeout: 5_000 }); - const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.data); + const savedBeforeEdit = JSON.stringify(server.records.get(projectId)?.workspace?.data); + // The workspace was saved with the project, so the checks below compare against a real copy. + expect(savedBeforeEdit).toEqual(expect.any(String)); const slider = strengthSlider()!; await act(() => { @@ -369,12 +398,15 @@ describe('WorkbenchProvider hidden page', () => { expect(activeProject()!.id).toBe(projectId); expect(strengthValue()).toBeCloseTo(typedStrength); - expect(JSON.stringify(server.records.get(projectId)?.data)).toBe(savedBeforeEdit); + expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).toBe(savedBeforeEdit); // Recovered as a pending edit, which the next page saves once it can. revivePage!(); - await vi.waitFor(() => expect(JSON.stringify(server.records.get(projectId)?.data)).not.toBe(savedBeforeEdit), { - timeout: 5_000, - }); + await vi.waitFor( + () => expect(JSON.stringify(server.records.get(projectId)?.workspace?.data)).not.toBe(savedBeforeEdit), + { + timeout: 5_000, + } + ); }); it('does not let the journal of a back/forward-cached page override work saved after it resumed', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index 71a5ecd08c0..85e5cd8e7d1 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -4,13 +4,21 @@ The backend project record is authoritative. Writes use `expected_revision`; a d `ProjectDocumentV3` allowlists editable document fields. Optional fields added within schema 3 (`lastFloatingGeometry`, `presetWorkingLayouts`) are additive: absent means none, so older schema-3 documents load unchanged, and a build that predates a field ignores it and drops it from the next document it saves to the server rather than refusing the project, which a schema bump would do. Queue runs, events, undo and per-workflow edit histories are not project documents. Documents are limited to 32 MiB of UTF-8 JSON on both sides of the API. +## Content and workspace + +The server holds a project as two documents (document schema 4): its **content**, what the project is and every member shares (name; the canvas document, its snapshots and revision counter; workflow entries), and each member's **workspace**, how they work on it (layout, widgets and generation settings, prompt history, project settings, the canvas selection and staging area, the open workflow and each workflow's last run). The browser keeps one whole document per project, in memory, drafts and the unload journal; `projectHalves.ts` splits it at the server boundary and joins the two halves back when a project loads. A member with no workspace yet joins onto the workspace a new project starts with. + +Each half has its own revision. A save sends only the half that changed: the content first (which raises the server's document floor to 4, so a build that predates the split is refused the project rather than saving it whole), then the workspace, one after the other, since the server takes one project write per account at a time. Each half is recorded as acknowledged as soon as it is, so a retry sends only what is still owed, and a content acknowledgement rebases the lineage's draft and journal entry before the workspace is sent, so recovery after a failed workspace save builds on the saved content. The draft is acknowledged when both are. A revision conflict in either half follows the same "changed elsewhere" flow, and the banner says when it is the caller's settings; using the server version then takes the server's workspace and keeps this tab's latest content, unless the server's content has moved since this tab's base, which is then reported as a content conflict instead. On a revision conflict a half the server holds as one of the page's own recent sends (its answer lost) is retried, not reported. A project the server still holds whole is split by its next save, whether or not anything changed, unless it is conflicted or refused; a project whose workflow repeats an item id (possible only before the split) keeps saving whole instead, and its first save, a save as a copy, or a duplicate of it creates it whole, since split content repeating one would be refused by every later load. A split project whose workflow comes to repeat one (only an editing bug could) is not saved, and the error asks for the change to be undone; export would flush the same save, so it cannot be the way out. + +Drafts and journal entries carry a base revision for each half (`baseRevision` for the content, `baseWorkspaceRevision`, absent in records written before the split, for the workspace). A recovered draft is judged half by half: a half equal to the server's needs no base, which covers a page that saved the content and was gone before the workspace, and a changed half conflicts only when the server moved it past the draft's base. + ## Workflows A project owns an ordered workflow collection (`workflows.entries`) and remembers its active workflow. Each entry carries the editable document, optional source metadata (the library template it was opened from or last explicitly saved to, with the content revision then observed, or `null` when unknown) and the newest successful run's output. Source metadata sits beside the document, outside graph edit history, so undoing an edit never undoes a save. `projectWorkflows.ts` owns membership, selection and the session histories (one per workflow, 40 entries per project across all of them, structurally shared); Workflow owns editing, serialization, library transport and presentation. Projects autosave their workflows. The library changes only through explicit publication (`features/workflow/data/publication.ts`): saving as a new template or updating the source at exactly the revision the copy last saw; a stale revision is a 409 the user resolves. Every media reference in every workflow, active or not, takes part in reference indexing, cleanup protection, duplication and archive export. -`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as the first, active workflow; a `libraryWorkflowId` becomes a source with an unknown revision, never assumed to match the library's current contents. Server loads, recovery drafts, duplication and `.invk` import all pass through it; every write path emits schema 3. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused rather than repaired or replaced with blanks; a refused document is reported, left untouched on the server, and its raw content stays available for export. Same-server duplication keeps source references; `.invk` import clears them, because a portable file never names a library write target. +`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as the first, active workflow; a `libraryWorkflowId` becomes a source with an unknown revision, never assumed to match the library's current contents. Server loads (after joining a split project's halves), recovery drafts, duplication and `.invk` import all pass through it; every local document is schema 3. Every save the sync engine makes is the split of one, except for a project whose workflow repeats an item id (above); `.invk` import and renaming a closed project the server holds whole still write a whole document, which the project's next save from an editor splits. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused rather than repaired or replaced with blanks; a refused document is reported, left untouched on the server, and its raw content stays available for export. Same-server duplication keeps source references; `.invk` import clears them, because a portable file never names a library write target. Canvas uploads carrying a project id await that identity’s acknowledged server creation through the mounted persistence service. Uploads waiting on an unacknowledged project share one serialized creation attempt, and a failed attempt answers further uploads for a few seconds before another is made; later uploads use the acknowledged identity without flushing unrelated edits. Only a closed project, an account change, engine disposal or the operation's own cancellation stops an upload, and those stop the wait immediately. Any other outcome (creation failed or offline, a conflict, or a server that no longer knows the project and refuses the id) sends the upload without a project id. Such uploads are never re-associated with the project later, so the project's intermediates cleanup does not see them. @@ -20,7 +28,7 @@ Project links are consumed after a successful open, so reloading restores the sa - Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. - Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications prevent one editor from overwriting another editor's unacknowledged work. -- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when the page is hidden or unloads, before the save starts, each open project whose document is not what the next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. It lives in its own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. An entry carries the account, project, lineage (an editor session, possibly another tab's that the session blob handed on), the editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a stage of the same document still in flight. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so there is at most one entry per lineage and writer, however long saves cannot be acknowledged. +- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when the page is hidden or unloads, before the save starts, each open project whose document is not what the next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. It lives in its own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. An entry carries the account, project, lineage (an editor session, possibly another tab's that the session blob handed on), the editor session of the page that wrote it, writer, base revisions and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a stage of the same document still in flight. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so there is at most one entry per lineage and writer, however long saves cannot be acknowledged. - **Settled mark.** A lineage's writer claim records the generation through which its writer has settled its journal: everything it acknowledged, discarded, or holds in recovery. The acknowledgement and the draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. It is an optional claim field, so claims written earlier read as having settled nothing and the workbench database stays at version 4; a mark of an earlier writer is ignored. It is what tells "never staged" from "acknowledged and moved on" once an acknowledgement has deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of the acknowledgement, can never come back. - The next load of the account reconciles every entry, in pages, before any writer claims a lineage. An entry becomes its lineage's next draft only where its writer could still have staged it: the lineage is unclaimed or still claimed by that writer, the writer has not settled that far, and the lineage holds no generation at or above the entry's. It keeps the stored draft's conflict, schema refusal, copy reservation, and base revision, as staging does, and then goes through the usual load rules, so a server revision the base no longer matches surfaces as a conflict. One exception: when the entry replaced a staged draft whose document the server now holds exactly (its save arrived but its answer did not), the draft is rebased onto that revision, as the late answer would have done, instead of conflicting with the writer's own save. An entry is discarded when another writer adopted, reclaimed, or retargeted the lineage, when its writer settled it, when the lineage already holds it or something newer, when it belongs to another account, or when its record schema is unknown or it is damaged. The draft is written before the entry is removed, so a crash in between leaves an entry the next pass discards as superseded. - Another tab's load reconciles only the entries of pages that are gone. A page proves it is still running by its editor session's Web Lock, which it holds until its last editor releases it or the page unloads or crashes; an entry whose writing page (the editor session it names, not the lineage it is written under) another page still holds is left in place (a merely hidden tab is still writing its lineages, and staging its entry elsewhere would hand that tab's unsaved edit to another tab, where an undo could never retire it). An entry written before it named its page is treated as a gone page's. The hidden page settles the entry with its own saves, or the next load after it is gone recovers it. A browser that keeps a page holding its session in the back/forward cache keeps the lock held, so the entry waits until the page is restored (and saves) or evicted; one that evicts such pages (Chromium today) releases the lock, and the entry is recovered as a gone page's. A reload of the same tab reclaims its session, retrying for up to half a second (five retries 100 ms apart) while the old document lets go, and then reconciles its own entries. Two cases fall outside it. If the old document holds the lock longer, the reload takes a fresh session, its own entries and staged drafts count as a running page's on that load, and a later load recovers them once the old document is gone. A duplicated tab waits the same half second before taking a session of its own; if the original reloads within it, the duplicate can take the original's session instead and then takes the original's entries and staged drafts, while the reloaded original takes a fresh session. Staged drafts follow the same rule: a load does not adopt, auto-open, or offer for recovery a draft whose lineage is named after another running page's session, and the editor route does not count it. A draft records its lineage, not the page staging it, so it is judged by the session that lineage is named after: a lineage isolated for an unopenable draft names no page and is adopted as before. A lineage the session blob hands on (today only isolated ones) is not judged at all: a loading page claims its handed-on lineage directly, whether or not the page staging it still runs. Without Web Locks (a plain-HTTP deployment, say) liveness cannot be told, so every entry is reconciled and every draft is adopted as before. A hidden event during save-as-new journals the source project: if the page goes away before the copy exists, the edit is recovered into the conflicted source and the retried copy keeps its reserved document; once the copy exists, the source lineage has moved, so the entry is discarded and the copy is never written from it. diff --git a/invokeai/frontend/webv2/src/workbench/projects/api.test.ts b/invokeai/frontend/webv2/src/workbench/projects/api.test.ts index 419809f28f6..7189e39a2d9 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/api.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/api.test.ts @@ -21,10 +21,13 @@ import { deleteProject, createProject, createProjectSettled, + getProjectDocumentSchemaRefusal, getProjectWriteSizeRefusal, getProject, isProjectCanvasSchemaUnsupportedError, ProjectCreateAbsentError, + saveFirstProjectWorkspace, + saveProjectWorkspace, updateProject, } from './api'; @@ -38,7 +41,12 @@ beforeEach(() => { describe('createProjectSettled', () => { const request = { data: {}, name: 'Imported', project_id: 'project-1' }; const post = () => ({ - body: JSON.stringify({ ...request, max_canvas_schema_version: 4, minimum_canvas_schema_version: 3 }), + body: JSON.stringify({ + ...request, + max_canvas_schema_version: 4, + max_document_schema_version: 4, + minimum_canvas_schema_version: 3, + }), method: 'POST', signal: expect.anything(), }); @@ -74,6 +82,7 @@ describe('createProjectSettled', () => { board_id: 'board-for-project-1', data: {}, minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: 'Imported', project_id: 'project-1', }); @@ -85,7 +94,7 @@ describe('createProjectSettled', () => { expect(transport.apiFetchJson).toHaveBeenNthCalledWith(2, '/api/v1/projects/', post()); expect(transport.apiFetchJson).toHaveBeenNthCalledWith( 3, - '/api/v1/projects/project-1?max_canvas_schema_version=4', + '/api/v1/projects/project-1?max_canvas_schema_version=4&max_document_schema_version=4', expect.anything() ); }); @@ -219,21 +228,24 @@ describe('canvas schema compatibility declarations', () => { await getProject('project/one'); - expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/project%2Fone?max_canvas_schema_version=4', { - signal: undefined, - }); + expect(transport.apiFetchJson).toHaveBeenCalledWith( + '/api/v1/projects/project%2Fone?max_canvas_schema_version=4&max_document_schema_version=4', + { signal: undefined } + ); }); it('declares both the written floor and supported maximum when creating', async () => { transport.apiFetchJson.mockResolvedValueOnce({ project_id: 'project-1' }); - await createProject({ data: {}, name: 'Project' }); + await createProject({ data: {}, minimum_document_schema_version: 4, name: 'Project' }); expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/', { body: JSON.stringify({ data: {}, + minimum_document_schema_version: 4, name: 'Project', max_canvas_schema_version: 4, + max_document_schema_version: 4, minimum_canvas_schema_version: 3, }), method: 'POST', @@ -258,6 +270,7 @@ describe('canvas schema compatibility declarations', () => { minimum_canvas_schema_version: 3, name: 'Project', max_canvas_schema_version: 4, + max_document_schema_version: 4, }), method: 'PUT', signal: undefined, @@ -447,3 +460,84 @@ describe('deleteProject', () => { ); }); }); + +describe('project workspaces', () => { + it('saves the caller’s workspace at its own route, declaring the document schema it understands', async () => { + transport.apiFetchJson.mockResolvedValueOnce({ data: {}, revision: 2, updated_at: '' }); + + await saveProjectWorkspace('project/one', { data: { layout: {} }, expected_revision: 1 }); + + expect(transport.apiFetchJson).toHaveBeenCalledWith('/api/v1/projects/project%2Fone/workspace', { + body: JSON.stringify({ data: { layout: {} }, expected_revision: 1, max_document_schema_version: 4 }), + method: 'PUT', + signal: undefined, + }); + }); + + it('recognizes a refusal of a newer document schema, and only that', () => { + const refusalOf = (detail: Record) => new ApiError(JSON.stringify({ detail }), 412); + + expect( + getProjectDocumentSchemaRefusal( + refusalOf({ + code: 'document_schema_unsupported', + max_document_schema_version: 4, + minimum_document_schema_version: 5, + }) + ) + ).toEqual({ maxDocumentSchemaVersion: 4, minimumDocumentSchemaVersion: 5 }); + for (const detail of [ + { code: 'canvas_schema_unsupported', max_document_schema_version: 4, minimum_document_schema_version: 5 }, + { code: 'document_schema_unsupported', max_document_schema_version: 5, minimum_document_schema_version: 5 }, + ]) { + expect(getProjectDocumentSchemaRefusal(refusalOf(detail))).toBeNull(); + } + }); + + it('keeps the workspace a retried create finds its first attempt already saved', async () => { + const workspace = { data: { layout: {} }, revision: 1, updated_at: '' }; + transport.apiFetchJson + .mockRejectedValueOnce(new TypeError('network error')) + .mockRejectedValueOnce(new ApiError('conflict', 409)) + .mockResolvedValueOnce({ + board_id: 'board', + data: {}, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, + name: 'Copy', + project_id: 'project-1', + workspace, + }); + const created = await createProjectSettled( + { data: {}, minimum_document_schema_version: 4, name: 'Copy', project_id: 'project-1' }, + captureAccountScope() + ); + + const copy = await saveFirstProjectWorkspace(created, { layout: { changed: true } }); + + expect(copy.workspace).toEqual(workspace); + expect(transport.apiFetchJson).toHaveBeenCalledTimes(3); + }); + + it('does not adopt a retried create whose project is still held whole when content was sent', async () => { + const conflict = new ApiError('conflict', 409); + transport.apiFetchJson + .mockRejectedValueOnce(new TypeError('network error')) + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + board_id: 'board', + data: {}, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, + name: 'Imported', + project_id: 'project-1', + }); + + await expect( + createProjectSettled( + { data: {}, minimum_document_schema_version: 4, name: 'Imported', project_id: 'project-1' }, + captureAccountScope() + ) + ).rejects.toBe(conflict); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/api.ts b/invokeai/frontend/webv2/src/workbench/projects/api.ts index ced36632c1f..f7f560f8b04 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/api.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/api.ts @@ -8,6 +8,8 @@ import { MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, } from '@workbench/canvasSchemaVersion'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; + /** Project records and client state are scoped to the authenticated account. */ const PROJECTS_BASE = '/api/v1/projects'; @@ -23,14 +25,31 @@ export interface ProjectSummaryDTO { name: string; revision: number; minimum_canvas_schema_version: number; + /** The oldest project document schema a client must read; 3 for a project saved whole, 4 once split. */ + minimum_document_schema_version: number; created_at: string; + /** When the project content was last saved. */ + updated_at: string; + /** When the caller last saved their workspace in the project; null before they have. */ + workspace_updated_at: string | null; +} + +export interface ProjectWorkspaceDTO { + data: Record; + revision: number; updated_at: string; } +/** What a write answers: the project content (from document schema 4; the whole document before it). */ export interface ProjectRecordDTO extends ProjectSummaryDTO { data: Record; } +/** What a read answers: the content and the caller's workspace beside it, null until their first save. */ +export interface ProjectWithWorkspaceDTO extends ProjectRecordDTO { + workspace: ProjectWorkspaceDTO | null; +} + export interface ProjectCreateRequest { project_id?: string; /** Adopt and rename an unclaimed private board, or omit to create one atomically with the project. */ @@ -39,6 +58,8 @@ export interface ProjectCreateRequest { data: Record; /** Raise the server's compatibility floor atomically with this document. */ minimum_canvas_schema_version?: number; + /** 4 when `data` is project content, which every save of a split project is; omitted for a document saved whole. */ + minimum_document_schema_version?: number; } export type ProjectBoardItemKind = 'image' | 'video'; @@ -70,14 +91,23 @@ export interface ProjectUpdateRequest { expected_revision: number; /** Raise the compatibility floor atomically with this save; omission preserves the stored floor. */ minimum_canvas_schema_version?: number; + /** 4 when `data` is project content, which splits a project saved whole; omission preserves the stored floor. */ + minimum_document_schema_version?: number; +} + +export interface ProjectWorkspaceSaveRequest { + data: Record; + /** The workspace revision this save replaces, or null for the caller's first workspace in the project. */ + expected_revision: number | null; } export const listProjects = (signal?: AbortSignal): Promise => apiFetchJson(`${PROJECTS_BASE}/`, { signal }); -export const getProject = (projectId: string, signal?: AbortSignal): Promise => - apiFetchJson( - `${PROJECTS_BASE}/${encodeURIComponent(projectId)}?max_canvas_schema_version=${MAX_SUPPORTED_CANVAS_SCHEMA_VERSION}`, +export const getProject = (projectId: string, signal?: AbortSignal): Promise => + apiFetchJson( + `${PROJECTS_BASE}/${encodeURIComponent(projectId)}?max_canvas_schema_version=${MAX_SUPPORTED_CANVAS_SCHEMA_VERSION}` + + `&max_document_schema_version=${PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION}`, { signal } ); @@ -150,6 +180,7 @@ const serializeCreateProjectRequest = (request: ProjectCreateRequest): string => JSON.stringify({ ...request, max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION, }); @@ -162,7 +193,8 @@ const projectRecordMatchesCreate = (record: ProjectRecordDTO, requestBody: strin JSON.stringify(record.data) === JSON.stringify(request.data) && (request.board_id === undefined || record.board_id === request.board_id) && record.minimum_canvas_schema_version >= - (request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION) + (request.minimum_canvas_schema_version ?? DEFAULT_PROJECT_CANVAS_SCHEMA_VERSION) && + record.minimum_document_schema_version >= (request.minimum_document_schema_version ?? 1) ); }; @@ -190,7 +222,11 @@ export const updateProject = ( request: ProjectUpdateRequest, signal?: AbortSignal ): Promise => { - const body = JSON.stringify({ ...request, max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION }); + const body = JSON.stringify({ + ...request, + max_canvas_schema_version: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + }); return requestProjectWrite( () => @@ -204,6 +240,43 @@ export const updateProject = ( ); }; +/** + * The caller's first workspace in a project just created with its content, saved beside it. A retried create that + * found its first attempt committed answers with a read, which carries that attempt's workspace if it got as far. + */ +export const saveFirstProjectWorkspace = async ( + record: ProjectRecordDTO, + data: Record, + signal?: AbortSignal, + save: typeof saveProjectWorkspace = saveProjectWorkspace +): Promise => { + const found = (record as Partial).workspace; + return { + ...record, + workspace: found ?? (await save(record.project_id, { data, expected_revision: null }, signal)), + }; +}; + +/** Saves the caller's workspace in a project; its revision is its own, apart from the content's. */ +export const saveProjectWorkspace = ( + projectId: string, + request: ProjectWorkspaceSaveRequest, + signal?: AbortSignal +): Promise => { + const body = JSON.stringify({ ...request, max_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION }); + + return requestProjectWrite( + () => + apiFetchJson(`${PROJECTS_BASE}/${encodeURIComponent(projectId)}/workspace`, { + body, + method: 'PUT', + signal, + }), + signal, + createProjectWriteRetryBudget() + ); +}; + /** What becomes of a deleted project's boards other than its inbox, which always goes with the project. */ export type DeleteProjectBoards = 'delete' | 'release'; @@ -269,6 +342,31 @@ export const getProjectCanvasSchemaCompatibilityRefusal = ( } }; +export interface ProjectDocumentSchemaRefusal { + minimumDocumentSchemaVersion: number; + maxDocumentSchemaVersion: number; +} + +/** The server refused a project whose document schema this build predates: a newer Invoke split it further. */ +export const getProjectDocumentSchemaRefusal = (error: unknown): ProjectDocumentSchemaRefusal | null => { + if (!(error instanceof ApiError) || error.status !== 412) { + return null; + } + try { + const detail = (JSON.parse(error.message) as { detail?: Record }).detail; + const minimum = detail?.minimum_document_schema_version; + const maximum = detail?.max_document_schema_version; + return detail?.code === 'document_schema_unsupported' && + Number.isSafeInteger(minimum) && + Number.isSafeInteger(maximum) && + (minimum as number) > (maximum as number) + ? { maxDocumentSchemaVersion: maximum as number, minimumDocumentSchemaVersion: minimum as number } + : null; + } catch { + return null; + } +}; + /** The server refused to expose or replace a project written with a newer canvas schema. */ export const isProjectCanvasSchemaUnsupportedError = (error: unknown): boolean => getProjectCanvasSchemaCompatibilityRefusal(error) !== null; diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx index 6c93f48875d..d3dbaf1da04 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx @@ -1,4 +1,4 @@ -import type { ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import { Icon, Portal, Spinner, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; @@ -117,7 +117,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos ); const openImportedProject = useCallback( - (record: ProjectRecordDTO) => { + (record: ProjectWithWorkspaceDTO) => { const result = persistence.adoptProjectRecord(record); if (result.status === 'loaded') { diff --git a/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts b/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts new file mode 100644 index 00000000000..4d1f9f09e44 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/documentSchemaVersion.ts @@ -0,0 +1,5 @@ +/** + * The server's project format from document schema 4: the project content and each member's workspace, saved apart + * (`projectHalves.ts`). Every save this build makes is of that format, and it reads nothing newer. + */ +export const PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION = 4; diff --git a/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts b/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts index d05f9705cc0..9bdf838e559 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/draftStore.contract.ts @@ -169,7 +169,7 @@ export const testProjectDraftStoreContract = (createStore: () => Promise +>; export interface ProjectDraftInput { baseMinimumCanvasSchemaVersion?: number; + /** The project content's revision the draft builds on. */ baseRevision: number | null; + /** The caller's workspace revision the draft builds on; absent (from builds before the split) means none. */ + baseWorkspaceRevision?: number | null; documentJson: string; documentSchemaVersion: number; editorSessionId: string; @@ -137,6 +148,11 @@ export type ProjectDraftGetResult = | { kind: 'empty'; writerState: ProjectDraftWriterClaim['state']; writerToken: string } | { kind: 'retargeted'; projectId: string; revision: number; writerToken: string } | { kind: 'corrupt' | 'missing' | 'unavailable' }; +/** A rebase changes only the bases: the caller, which holds the draft, applies them itself (`withBases`). */ +export type ProjectDraftRebaseResult = + | { kind: 'rebased' } + | { kind: 'corrupt' | 'fenced' | 'missing' | 'quota' | 'stale' | 'unavailable' }; + export type ProjectDraftSettlementResult = | { draft: ProjectDraft; kind: 'marked' | 'rebased' } | { draft: ProjectDraft | null; kind: 'retargeted' } @@ -166,6 +182,7 @@ export interface ProjectUnloadJournalEntry { accountId: string; baseMinimumCanvasSchemaVersion?: number; baseRevision: number | null; + baseWorkspaceRevision?: number | null; documentByteSize: number; documentJson: string; documentSchemaVersion: number; @@ -240,6 +257,8 @@ export type ProjectUnloadJournalDecision = export interface RetargetAcknowledgedCopyOptions { acknowledgedRevision: number; + /** The copy's workspace revision; omitted, the draft keeps none. */ + acknowledgedWorkspaceRevision?: number | null; copyProjectId: string; editorSessionId: string; projectId: string; @@ -323,12 +342,25 @@ export interface ProjectDraftStore { proposed: ProjectDraftCopyReservation, replaceCopyProjectId?: string ): Promise; - resumeSchemaRefused( + /** A schema-refused or conflicted draft back to dirty: its refusal or conflict is resolved by keeping it. */ + resumeDraft( projectId: string, editorSessionId: string, writerToken: string, generation: number ): Promise; + /** + * The lineage's draft (at or above `generation`) rebased onto what the server acknowledged of one half or both, + * keeping its document and state: a half saved while the draft held newer edits, or a save that landed with its + * answer lost. Omitted bases are kept. + */ + rebaseDraft( + projectId: string, + editorSessionId: string, + writerToken: string, + generation: number, + bases: ProjectDraftBases + ): Promise; retargetAcknowledgedCopy(options: RetargetAcknowledgedCopyOptions): Promise; /** Also settles the writer's unload journal through `sentGeneration`, in the same transaction. */ settleAcknowledgement( @@ -337,7 +369,9 @@ export interface ProjectDraftStore { writerToken: string, sentGeneration: number, acknowledgedRevision: number, - acknowledgedMinimumCanvasSchemaVersion?: number + acknowledgedMinimumCanvasSchemaVersion?: number, + /** The workspace revision acknowledged with the content; undefined keeps the draft's. */ + acknowledgedWorkspaceRevision?: number | null ): Promise; settleConflict( projectId: string, @@ -406,6 +440,8 @@ const isPositiveInteger = (value: unknown): value is number => const isNonNegativeInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; +const isOptionalBaseRevision = (value: unknown): boolean => + value === undefined || value === null || isPositiveInteger(value); type ProjectDraftCandidate = Partial & { conflict?: { kind?: unknown; serverRevision?: unknown }; @@ -424,6 +460,7 @@ const isProjectDraftCandidate = (draft: ProjectDraftCandidate, requireDocument: const hasReservation = reservationMetadata.some((value) => value !== undefined); if ( !(draft.baseRevision === null || isPositiveInteger(draft.baseRevision)) || + !isOptionalBaseRevision(draft.baseWorkspaceRevision) || (draft.baseMinimumCanvasSchemaVersion !== undefined && !isPositiveInteger(draft.baseMinimumCanvasSchemaVersion)) || !isNonNegativeInteger(draft.documentByteSize) || (requireDocument && typeof draft.documentJson !== 'string') || @@ -481,6 +518,7 @@ export const isProjectDraftInput = (value: unknown): value is ProjectDraftInput const input = value as Partial; return ( (input.baseRevision === null || isPositiveInteger(input.baseRevision)) && + isOptionalBaseRevision(input.baseWorkspaceRevision) && (input.baseMinimumCanvasSchemaVersion === undefined || isPositiveInteger(input.baseMinimumCanvasSchemaVersion)) && typeof input.documentJson === 'string' && isPositiveInteger(input.documentSchemaVersion) && @@ -676,6 +714,12 @@ export const toDirtyProjectDraft = (draft: ProjectDraft, changes: Partial ({ + ...draft, + ...Object.fromEntries(Object.entries(bases).filter(([, value]) => value !== undefined)), +}); + export const toConflictProjectDraft = (draft: ProjectDraft, conflict: ProjectDraftConflict): ProjectDraft => { const next: Record = { ...draft, conflict, state: 'conflict' }; delete next.refusal; @@ -701,6 +745,7 @@ const isProjectUnloadJournalEntry = ( entry.schemaVersion === PROJECT_UNLOAD_JOURNAL_SCHEMA_VERSION && isNonEmptyString(entry.accountId) && (entry.baseRevision === null || isPositiveInteger(entry.baseRevision)) && + isOptionalBaseRevision(entry.baseWorkspaceRevision) && (entry.baseMinimumCanvasSchemaVersion === undefined || isPositiveInteger(entry.baseMinimumCanvasSchemaVersion)) && isPositiveInteger(entry.documentSchemaVersion) && isNonEmptyString(entry.editorSessionId) && @@ -816,6 +861,7 @@ export const decideUnloadJournalEntry = ({ : { baseMinimumCanvasSchemaVersion: entry.baseMinimumCanvasSchemaVersion }), ...documentFields, baseRevision: entry.baseRevision, + ...(entry.baseWorkspaceRevision === undefined ? {} : { baseWorkspaceRevision: entry.baseWorkspaceRevision }), editorSessionId: entry.editorSessionId, projectId: entry.projectId, state: 'dirty', @@ -842,7 +888,8 @@ export const createUnavailableProjectDraftStore = (): ProjectDraftStore => ({ reconcileUnloadJournal: () => Promise.resolve({ kind: 'unavailable' }), settleUnloadJournal: () => Promise.resolve({ kind: 'unavailable' }), reserveCopyIdentity: () => Promise.resolve({ kind: 'unavailable' }), - resumeSchemaRefused: () => Promise.resolve({ kind: 'unavailable' }), + rebaseDraft: () => Promise.resolve({ kind: 'unavailable' }), + resumeDraft: () => Promise.resolve({ kind: 'unavailable' }), retargetAcknowledgedCopy: () => Promise.resolve({ kind: 'unavailable' }), settleAcknowledgement: () => Promise.resolve({ kind: 'unavailable' }), settleConflict: () => Promise.resolve({ kind: 'unavailable' }), @@ -1289,7 +1336,18 @@ export const createMemoryProjectDraftStore = ({ bumpWriterClaim(draftKey(projectId, editorSessionId)); return Promise.resolve({ ...reservation, kind: 'reserved' }); }, - resumeSchemaRefused(projectId, editorSessionId, writerToken, generation) { + rebaseDraft(projectId, editorSessionId, writerToken, generation, bases) { + const settled = settle( + projectId, + editorSessionId, + writerToken, + generation, + (draft) => withBases(draft, bases), + 'rebased' + ); + return Promise.resolve(settled.kind === 'rebased' ? { kind: 'rebased' } : (settled as ProjectDraftRebaseResult)); + }, + resumeDraft(projectId, editorSessionId, writerToken, generation) { return Promise.resolve( settle(projectId, editorSessionId, writerToken, generation, (draft) => toDirtyProjectDraft(draft, {}), 'marked') ); @@ -1363,6 +1421,7 @@ export const createMemoryProjectDraftStore = ({ ? null : toDirtyProjectDraft(record, { baseRevision: options.acknowledgedRevision, + baseWorkspaceRevision: options.acknowledgedWorkspaceRevision, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectId: undefined, @@ -1406,7 +1465,8 @@ export const createMemoryProjectDraftStore = ({ writerToken, sentGeneration, acknowledgedRevision, - acknowledgedMinimumCanvasSchemaVersion + acknowledgedMinimumCanvasSchemaVersion, + acknowledgedWorkspaceRevision ) { if (isClosed) { return Promise.resolve({ kind: 'unavailable' }); @@ -1431,6 +1491,9 @@ export const createMemoryProjectDraftStore = ({ ...record, baseMinimumCanvasSchemaVersion: acknowledgedMinimumCanvasSchemaVersion ?? record.baseMinimumCanvasSchemaVersion, baseRevision: acknowledgedRevision, + ...(acknowledgedWorkspaceRevision === undefined + ? {} + : { baseWorkspaceRevision: acknowledgedWorkspaceRevision }), }); bumpWriterClaim(draftKey(projectId, editorSessionId)); return Promise.resolve({ draft, kind: 'rebased' }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 41f58adfd61..9022a28b509 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -7,7 +7,7 @@ import { createAccountOwnedQueueRunJournal } from '@workbench/queue-integration/ import { createDraftProject, createInitialWorkbenchState } from '@workbench/workbenchState'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectRecordDTO, ProjectSummaryDTO } from './api'; +import type { ProjectRecordDTO, ProjectSummaryDTO, ProjectWithWorkspaceDTO } from './api'; import type { ProjectDraftStore } from './draftStore'; import type { WorkbenchSessionBlob } from './session'; @@ -25,6 +25,8 @@ import { } from './durableSyncedPersistence'; import { createDeterministicProjectId } from './ids'; import { serializeProjectDocumentV3, serializeProjectDocumentV3Json } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; +import { deserializeProjectRecord } from './projectHydration'; import { acquireProjectMutationLock } from './projectLifecycleLocks'; import { getProjectSyncSnapshot, registerOpenProject, unregisterOpenProject } from './syncStore'; @@ -57,45 +59,78 @@ const stateWith = (projects: Project[]): WorkbenchState => ({ projects, }); -const toRecord = (project: Project, revision = 1): ProjectRecordDTO => ({ +/** A project as a build before the split saved it: one whole document. */ +const toRecord = (project: Project, revision = 1): ProjectWithWorkspaceDTO => ({ board_id: `board-${project.id}`, created_at: now, data: serializeProjectDocumentV3(project), minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: project.name, project_id: project.id, revision, updated_at: now, + workspace: null, + workspace_updated_at: null, }); -const toSummary = (record: ProjectRecordDTO): ProjectSummaryDTO => ({ +/** A project as this build saves it: content, and the caller's workspace beside it. */ +const toSplitRecord = (project: Project, revision = 1, workspaceRevision = 1): ProjectWithWorkspaceDTO => { + const halves = splitProjectDocument(serializeProjectDocumentV3(project)); + return { + ...toRecord(project, revision), + data: halves.content as unknown as Record, + minimum_document_schema_version: 4, + workspace: { + data: halves.workspace as unknown as Record, + revision: workspaceRevision, + updated_at: now, + }, + workspace_updated_at: now, + }; +}; + +/** What the server answers a create of `project` with: its content, and no workspace yet. */ +const toCreatedRecord = (project: Project): ProjectRecordDTO => { + const { workspace: _workspace, ...created } = toSplitRecord(project); + return { ...created, workspace_updated_at: null }; +}; + +const toSummary = (record: ProjectWithWorkspaceDTO): ProjectSummaryDTO => ({ board_id: record.board_id, created_at: record.created_at, minimum_canvas_schema_version: record.minimum_canvas_schema_version, + minimum_document_schema_version: record.minimum_document_schema_version, name: record.name, project_id: record.project_id, revision: record.revision, updated_at: record.updated_at, + workspace_updated_at: record.workspace_updated_at, }); const createApi = () => { - const records = new Map(); - const api: DurableProjectPersistenceApi & { records: Map } = { + const records = new Map(); + const api: DurableProjectPersistenceApi & { records: Map } = { records, createProject: vi.fn((request) => { const id = request.project_id!; - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${id}`, created_at: now, data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: id, revision: 1, updated_at: now, + workspace: null, + workspace_updated_at: null, }; records.set(id, record); - return Promise.resolve(structuredClone(record)); + // A write's record carries no workspace. + const { workspace: _workspace, ...written } = structuredClone(record); + return Promise.resolve(written); }), deleteProject: vi.fn((projectId) => { records.delete(projectId); @@ -111,6 +146,22 @@ const createApi = () => { }), loadSession: vi.fn(() => Promise.resolve(null)), listProjects: vi.fn(() => Promise.resolve([...records.values()].map(toSummary))), + saveProjectWorkspace: vi.fn((projectId, request) => { + const current = records.get(projectId); + if (!current) { + return Promise.reject(Object.assign(new Error('not found'), { status: 404 })); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + return Promise.reject(Object.assign(new Error('conflict'), { status: 409 })); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + }; + records.set(projectId, { ...current, workspace, workspace_updated_at: now }); + return Promise.resolve(structuredClone(workspace)); + }), saveSession: vi.fn(() => Promise.resolve()), updateProject: vi.fn((projectId, request) => { const current = records.get(projectId); @@ -120,19 +171,24 @@ const createApi = () => { if (current.revision !== request.expected_revision) { return Promise.reject(Object.assign(new Error('conflict'), { status: 409 })); } - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { ...current, data: structuredClone(request.data), minimum_canvas_schema_version: Math.max( current.minimum_canvas_schema_version, request.minimum_canvas_schema_version ?? current.minimum_canvas_schema_version ), + minimum_document_schema_version: Math.max( + current.minimum_document_schema_version, + request.minimum_document_schema_version ?? current.minimum_document_schema_version + ), name: request.name, revision: current.revision + 1, updated_at: now, }; records.set(projectId, record); - return Promise.resolve(structuredClone(record)); + const { workspace: _workspace, ...written } = structuredClone(record); + return Promise.resolve(written); }), }; return api; @@ -193,7 +249,8 @@ describe('durable project persistence', () => { await vi.waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); expect(ready).not.toHaveBeenCalled(); - acknowledge(toRecord(project)); + api.records.set(project.id, { ...toCreatedRecord(project), workspace: null }); + acknowledge(toCreatedRecord(project)); await Promise.all([first, second]); await service.ensureProjectOnServer({ ...project, name: 'Unsaved later edit' }); expect(api.createProject).toHaveBeenCalledOnce(); @@ -228,7 +285,7 @@ describe('durable project persistence', () => { const rejected = expect(pending).rejects.toMatchObject({ reason: 'superseded' }); await vi.waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); service.markProjectDeleted(project.id); - acknowledge(toRecord(project)); + acknowledge(toCreatedRecord(project)); await rejected; }); @@ -1531,7 +1588,7 @@ describe('durable project persistence', () => { const draftStore = createMemoryProjectDraftStore(); const project = createDraftProject([]); const futureRecord = toRecord(project, 2); - futureRecord.data = { ...futureRecord.data, documentSchemaVersion: 4 }; + futureRecord.data = { ...futureRecord.data, documentSchemaVersion: 5 }; api.records.set(project.id, futureRecord); await draftStore.stage({ baseRevision: 1, @@ -1559,7 +1616,7 @@ describe('durable project persistence', () => { expect(api.updateProject).not.toHaveBeenCalled(); await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ draft: { - refusal: { documentSchemaVersion: 4, kind: 'document', maxDocumentSchemaVersion: 3 }, + refusal: { documentSchemaVersion: 5, kind: 'document', maxDocumentSchemaVersion: 4 }, state: 'schema-refused', }, kind: 'found', @@ -1684,8 +1741,21 @@ describe('durable project persistence', () => { await service.saveWorkbench(loaded.state); await service.saveWorkbench(loaded.state); + // Split once: the content raises the document floor, and the workspace is saved beside it for the first time. expect(api.updateProject).toHaveBeenCalledOnce(); - expect(vi.mocked(api.updateProject).mock.calls[0]![1].data.documentSchemaVersion).toBe(3); + expect(vi.mocked(api.updateProject).mock.calls[0]![1]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + }); + expect(api.saveProjectWorkspace).toHaveBeenCalledOnce(); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls[0]![1]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + expected_revision: null, + }); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + workspace: { revision: 1 }, + }); }); it.each([ @@ -2025,7 +2095,7 @@ describe('durable project persistence', () => { openProjectIds: [project.id], }); const responseLost = new TypeError('response lost'); - let committedCopy: ProjectRecordDTO | null = null; + let committedCopy: ProjectWithWorkspaceDTO | null = null; const saveDraftAsNew = vi.fn().mockImplementation((input) => { if (!committedCopy) { committedCopy = { @@ -2033,10 +2103,13 @@ describe('durable project persistence', () => { created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(input.copyProjectId, committedCopy); return Promise.reject(responseLost); @@ -2118,15 +2191,18 @@ describe('durable project persistence', () => { const saveDraftAsNew = vi.fn(async (input) => { signalCreateStarted(); await createReleased; - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${input.copyProjectId}`, created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(record.project_id, record); return record; @@ -2159,7 +2235,8 @@ describe('durable project persistence', () => { await saving; expect(api.records.get(copy.targetProjectId)?.name).toBe(`${local.name} (copy)`); - expect(api.records.get(copy.targetProjectId)?.data.settings).toEqual( + // Settings are the workspace's. + expect(api.records.get(copy.targetProjectId)?.workspace?.data.settings).toEqual( serializeProjectDocumentV3(backgroundEdit).settings ); }); @@ -2902,7 +2979,7 @@ describe('durable project persistence', () => { const draftStore: ProjectDraftStore = { ...backingStore, stage }; const project = createDraftProject([]); const remote = { ...project, name: 'remote edit' }; - api.records.set(project.id, toRecord(remote, 2)); + api.records.set(project.id, toSplitRecord(remote, 2)); await draftStore.stage({ baseRevision: 1, documentJson: JSON.stringify(serializeProjectDocumentV3({ ...project, name: 'discarded local edit' })), @@ -3117,15 +3194,18 @@ describe('durable project persistence', () => { openProjectIds: [project.id], }); const saveDraftAsNew = vi.fn((input) => { - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${input.copyProjectId}`, created_at: now, data: structuredClone(input.document), minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: 3, name: input.name, project_id: input.copyProjectId, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; api.records.set(record.project_id, record); return Promise.resolve(record); @@ -3441,3 +3521,789 @@ describe('unload journal', () => { expect(reloaded.conflicts).toEqual([]); }); }); + +describe('project content and the caller’s workspace', () => { + const openSplitProject = async (record: ProjectWithWorkspaceDTO, draftStore = createMemoryProjectDraftStore()) => { + const owner = captureAccountScope(); + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(owner, api, draftStore); + const loaded = await service.loadWorkbench(); + return { api, conflicts: loaded.conflicts, project: loaded.state.projects[0]!, service }; + }; + const withOtherSettings = (project: Project): Project => ({ + ...project, + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }); + + it('saves a change to how one works on the project as the workspace alone', async () => { + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([]))); + + await service.saveWorkbench(stateWith([withOtherSettings(project)])); + + expect(api.updateProject).not.toHaveBeenCalled(); + expect(vi.mocked(api.saveProjectWorkspace).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + }); + + it('saves a change to the shared project as the content alone', async () => { + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([]))); + + await service.saveWorkbench(stateWith([{ ...project, name: 'Renamed' }])); + + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(vi.mocked(api.updateProject).mock.calls.map(([, request]) => request.expected_revision)).toEqual([1]); + expect(api.records.get(project.id)).toMatchObject({ name: 'Renamed', revision: 2, workspace: { revision: 1 } }); + }); + + it('replays a workspace a closed page never saved after its content, without a conflict', async () => { + const project = { ...createDraftProject([]), name: 'Saved before closing' }; + const draftStore = createMemoryProjectDraftStore(); + // The page saved the content (revision 2) and was gone before saving the workspace or hearing back. + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(withOtherSettings(project))), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + + const { + api, + conflicts, + project: loaded, + service, + } = await openSplitProject(toSplitRecord(project, 2, 1), draftStore); + await service.saveWorkbench(stateWith([loaded])); + + expect(conflicts).toEqual([]); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(api.records.get(project.id)?.workspace).toMatchObject({ + data: { settings: withOtherSettings(project).settings }, + revision: 2, + }); + }); + + it('reports a workspace saved meanwhile from another tab as a conflict of the workspace alone', async () => { + const project = createDraftProject([]); + const draftStore = createMemoryProjectDraftStore(); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(withOtherSettings(project))), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + + const { conflicts } = await openSplitProject(toSplitRecord(elsewhere, 1, 2), draftStore); + + expect(conflicts).toMatchObject([{ half: 'workspace', kind: 'revision', projectId: project.id }]); + }); + + it('stops saving a project a newer Invoke split further, keeping the local work', async () => { + const draftStore = createMemoryProjectDraftStore(); + const { api, project, service } = await openSplitProject(toSplitRecord(createDraftProject([])), draftStore); + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce( + new ApiError( + JSON.stringify({ + detail: { + code: 'document_schema_unsupported', + max_document_schema_version: 4, + minimum_document_schema_version: 5, + }, + }), + 412 + ) + ); + + const saved = await service.saveWorkbench(stateWith([withOtherSettings(project)])); + + expect(saved.hasPendingChanges).toBe(true); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { + refusal: { documentSchemaVersion: 5, kind: 'document', maxDocumentSchemaVersion: 4 }, + state: 'schema-refused', + }, + }); + }); +}); + +describe('a push that saved one half and not the other', () => { + it('still saves the content when an undo returns to what was last acknowledged whole', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(owner, api); + const loaded = (await service.loadWorkbench()).state.projects[0]!; + // An edit to both halves: the content lands, the workspace does not. + const edited = { + ...loaded, + name: 'Edited', + settings: { ...loaded.settings, useCpuNoise: !loaded.settings.useCpuNoise }, + }; + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([edited])); + expect(api.records.get(project.id)?.name).toBe('Edited'); + + // Undone before the retry: the server holds the edited content, so it must be saved back. + await service.saveWorkbench(stateWith([loaded])); + + expect(api.records.get(project.id)).toMatchObject({ name: loaded.name, revision: 3 }); + }); +}); + +describe('recovering a project whose halves were acknowledged apart', () => { + const openWith = async ( + api: ReturnType, + projectId: string, + draftStore: ProjectDraftStore = createMemoryProjectDraftStore() + ) => { + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: projectId, + openProjectIds: [projectId], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + return { conflicts: loaded.conflicts, project: loaded.state.projects[0]!, service }; + }; + + it('builds on its own content save when the workspace save after it failed and the page went away', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + const { project: loaded, service } = await openWith(api, project.id, draftStore); + // The content lands; the workspace save after it does not. + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench( + stateWith([ + { ...loaded, name: 'First', settings: { ...loaded.settings, useCpuNoise: !loaded.settings.useCpuNoise } }, + ]) + ); + expect(api.records.get(project.id)).toMatchObject({ name: 'First', revision: 2 }); + // A further edit reaches only the draft before the page goes away. + vi.mocked(api.updateProject).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([{ ...loaded, name: 'Second' }])); + + const reloaded = await openWith(api, project.id, draftStore); + + expect(reloaded.conflicts).toEqual([]); + await reloaded.service.saveWorkbench(stateWith([reloaded.project])); + expect(api.records.get(project.id)).toMatchObject({ name: 'Second', revision: 3 }); + }); + + it('keeps this tab’s project edits when a workspace conflict is resolved with the saved workspace', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + api.records.set(project.id, toSplitRecord(elsewhere, 1, 2)); + const { conflicts, service } = await openWith(api, project.id, draftStore); + expect(conflicts).toMatchObject([{ half: 'workspace' }]); + + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + await service.saveWorkbench(stateWith([resolved.project])); + + expect(resolved.project).toMatchObject({ name: 'Edited here', promptHistory: elsewhere.promptHistory }); + expect(api.records.get(project.id)).toMatchObject({ name: 'Edited here', revision: 2, workspace: { revision: 2 } }); + }); +}); + +describe('a save the server answers with a revision conflict', () => { + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + const project = (await service.loadWorkbench()).state.projects[0]!; + return { api, project, service }; + }; + const prompted = (project: Project, prompt: string): Project => ({ + ...project, + promptHistory: [{ negativePrompt: null, positivePrompt: prompt }], + }); + const conflict = () => Object.assign(new Error('conflict'), { status: 409 }); + + it('reports another tab’s workspace as a conflict of the workspace, and sends it nothing more', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toSplitRecord(project)); + // Another tab saves its own workspace meanwhile. + const elsewhere = toSplitRecord(prompted(project, 'another tab'), 1, 2); + api.records.set(project.id, elsewhere); + + const saved = await service.saveWorkbench(stateWith([prompted(loaded, 'this tab')])); + + expect(saved.conflicts).toMatchObject([{ half: 'workspace', kind: 'revision' }]); + expect(api.saveProjectWorkspace).toHaveBeenCalledOnce(); + expect(api.records.get(project.id)?.workspace).toEqual(elsewhere.workspace); + }); + + it('acknowledges a workspace the server already holds as this tab’s', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toSplitRecord(project)); + const edited = prompted(loaded, 'this tab'); + // The same bytes reached the server another way (another tab of this account typed the same). + api.records.set(project.id, toSplitRecord(edited, 1, 2)); + + const saved = await service.saveWorkbench(stateWith([edited])); + + expect(saved.conflicts).toEqual([]); + expect(saved.hasPendingChanges).toBe(false); + expect(api.saveProjectWorkspace).toHaveBeenCalledOnce(); + }); + + it('retries a content save once when only the revision moved, and calls a second refusal a conflict', async () => { + const project = createDraftProject([]); + const retried = await open(toSplitRecord(project)); + // A save of the same bytes moved the revision on. + retried.api.records.set(project.id, toSplitRecord(project, 2)); + await retried.service.saveWorkbench(stateWith([{ ...retried.project, name: 'Renamed' }])); + expect(retried.api.updateProject).toHaveBeenCalledTimes(2); + expect(retried.api.records.get(project.id)).toMatchObject({ name: 'Renamed', revision: 3 }); + + const refused = await open(toSplitRecord(project)); + vi.mocked(refused.api.updateProject).mockRejectedValueOnce(conflict()).mockRejectedValueOnce(conflict()); + const saved = await refused.service.saveWorkbench(stateWith([{ ...refused.project, name: 'Renamed' }])); + expect(refused.api.updateProject).toHaveBeenCalledTimes(2); + expect(saved.conflicts).toMatchObject([{ kind: 'revision' }]); + expect(saved.conflicts[0]).not.toHaveProperty('half'); + }); + + it('retries the split of a project the server holds whole when only its revision moved', async () => { + const project = createDraftProject([]); + const { api, project: loaded, service } = await open(toRecord(project)); + api.records.set(project.id, toRecord(project, 2)); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Renamed' }])); + + expect(saved.conflicts).toEqual([]); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + name: 'Renamed', + revision: 3, + workspace: { revision: 1 }, + }); + }); +}); + +describe('saving the two halves', () => { + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + const project = (await service.loadWorkbench()).state.projects[0]!; + return { api, project, service }; + }; + const bothEdited = (project: Project): Project => ({ + ...project, + name: 'Renamed', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }); + + it('sends only the workspace on the next save when the content already landed', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([bothEdited(project)])); + + await service.saveWorkbench(stateWith([bothEdited(project)])); + + expect(api.updateProject).toHaveBeenCalledOnce(); + expect(api.saveProjectWorkspace).toHaveBeenCalledTimes(2); + expect(api.records.get(project.id)).toMatchObject({ revision: 2, workspace: { revision: 2 } }); + }); + + it('sends nothing of the workspace while the content it follows has not landed', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + vi.mocked(api.updateProject).mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const saved = await service.saveWorkbench(stateWith([bothEdited(project)])); + + expect(saved.hasPendingChanges).toBe(true); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); + + it('creates a new project as content, under the split floor, before its workspace', async () => { + const api = createApi(); + const project = createDraftProject([]); + + await createService(captureAccountScope(), api).saveWorkbench(stateWith([project])); + + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + }); + expect(vi.mocked(api.createProject).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(api.saveProjectWorkspace).mock.invocationCallOrder[0]! + ); + expect(api.records.get(project.id)).toMatchObject({ + minimum_document_schema_version: 4, + workspace: { revision: 1 }, + }); + }); +}); + +describe('saving a conflicted draft as a new project', () => { + it('creates the copy’s content, then saves this tab’s workspace beside it', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const source = createDraftProject([]); + api.records.set(source.id, toSplitRecord(source, 2)); + const local = { + ...source, + name: 'local edit', + settings: { ...source.settings, useCpuNoise: !source.settings.useCpuNoise }, + }; + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3(local)), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: source.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: source.id, + openProjectIds: [source.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + expect(loaded.conflicts).toMatchObject([{ kind: 'revision' }]); + + const copy = await service.resolveConflictSaveAsNew(loaded.state.projects[0]!); + + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 4 }, + minimum_document_schema_version: 4, + }); + expect(api.records.get(copy.targetProjectId)?.workspace).toMatchObject({ + data: { settings: local.settings }, + revision: 1, + }); + expect(copy.project.settings).toEqual(local.settings); + }); +}); + +describe('recovering a draft against a split project', () => { + const recover = async ( + record: ProjectWithWorkspaceDTO, + draft: { base: number; workspaceBase?: number; project: Project } + ) => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + api.records.set(record.project_id, record); + await draftStore.stage({ + baseRevision: draft.base, + ...(draft.workspaceBase === undefined ? {} : { baseWorkspaceRevision: draft.workspaceBase }), + documentJson: JSON.stringify(serializeProjectDocumentV3(draft.project)), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: record.project_id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api, draftStore); + return { api, loaded: await service.loadWorkbench(), service }; + }; + + it('needs no base for a workspace the server already holds as the draft’s', async () => { + // As a page writes a draft: from the project it loaded from the server. + const loaded = deserializeProjectRecord(toSplitRecord(createDraftProject([]))); + if (loaded.status !== 'loaded') { + throw new Error('Expected the project to load.'); + } + const project = loaded.project; + const settled = { ...project, settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise } }; + // The page saved its workspace (revision 2) and was gone before it heard back; a rename after it never left. + const recovered = await recover(toSplitRecord(settled, 1, 2), { + base: 1, + project: { ...settled, name: 'Renamed after' }, + workspaceBase: 1, + }); + await recovered.service.saveWorkbench(recovered.loaded.state); + + expect(recovered.loaded.conflicts).toEqual([]); + expect(recovered.api.records.get(project.id)).toMatchObject({ + name: 'Renamed after', + revision: 2, + workspace: { revision: 2 }, + }); + }); + + it('replays a draft from before the split onto a project split meanwhile, with no workspace yet', async () => { + const project = createDraftProject([]); + const edited = { ...project, settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise } }; + // Its content landed as the split (revision 2); the page went before saving the workspace. Its draft is a + // record from before the split: no workspace base at all. + const record = { ...toSplitRecord(project, 2), workspace: null, workspace_updated_at: null }; + + const { api, loaded, service } = await recover(record, { base: 1, project: edited }); + await service.saveWorkbench(loaded.state); + + expect(loaded.conflicts).toEqual([]); + expect(api.records.get(project.id)?.workspace).toMatchObject({ data: { settings: edited.settings }, revision: 1 }); + }); + + it.each([ + [ + 'canvas', + { code: 'canvas_schema_unsupported', max_canvas_schema_version: 4, minimum_canvas_schema_version: 5 }, + { kind: 'canvas', minimumCanvasSchemaVersion: 5 }, + ], + [ + 'document', + { code: 'document_schema_unsupported', max_document_schema_version: 4, minimum_document_schema_version: 5 }, + { documentSchemaVersion: 5, kind: 'document' }, + ], + ])('keeps a draft refused by a newer %s schema on open, and saves nothing', async (_floor, detail, refusal) => { + const project = createDraftProject([]); + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + api.records.set(project.id, toSplitRecord(project)); + await draftStore.stage({ + baseRevision: 1, + documentJson: JSON.stringify(serializeProjectDocumentV3({ ...project, name: 'local edit' })), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const service = createService(captureAccountScope(), api, draftStore); + await service.loadWorkbench(); + vi.mocked(api.getProject).mockRejectedValueOnce(new ApiError(JSON.stringify({ detail }), 412)); + + const opened = await service.hydrateProjectFromServer(project.id); + + expect(opened).toMatchObject({ project: { name: 'local edit' }, status: 'loaded' }); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { refusal, state: 'schema-refused' }, + }); + }); + + it('refuses a listed project a newer document schema wrote without reading it', async () => { + const project = createDraftProject([]); + const api = createApi(); + api.records.set(project.id, { ...toSplitRecord(project), minimum_document_schema_version: 5 }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + + const loaded = await createService(captureAccountScope(), api).loadWorkbench(); + + expect(api.getProject).not.toHaveBeenCalled(); + expect(loaded.refusedProjects).toMatchObject([ + { projectId: project.id, refusal: { status: 'unsupported-version', version: 5 }, source: 'project-document' }, + ]); + }); +}); + +describe('resolving a workspace conflict with the saved workspace', () => { + const conflicted = async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + const elsewhere = { ...project, promptHistory: [{ negativePrompt: null, positivePrompt: 'from another tab' }] }; + api.records.set(project.id, toSplitRecord(elsewhere, 1, 2)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + const loaded = await service.loadWorkbench(); + expect(loaded.conflicts).toMatchObject([{ half: 'workspace' }]); + return { api, draftStore, elsewhere, project, service }; + }; + + it('reports content another tab saved meanwhile as a conflict, and overwrites nothing', async () => { + const { api, draftStore, elsewhere, project, service } = await conflicted(); + // Another tab saves the project's content after the conflict was raised. + api.records.set(project.id, toSplitRecord({ ...elsewhere, name: 'Other tab content' }, 2, 2)); + + await expect(service.resolveConflictUseServer(project.id)).rejects.toThrow('changed elsewhere as well'); + service.abortProjectResolution(project.id); + await service.saveWorkbench(stateWith([{ ...project, name: 'Edited here' }])); + + expect(api.records.get(project.id)).toMatchObject({ name: 'Other tab content', revision: 2 }); + await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ + draft: { conflict: { kind: 'revision', serverRevision: 2 }, state: 'conflict' }, + }); + }); + + it('builds later edits on the saved workspace, so a page gone before saving them replays them', async () => { + const { api, draftStore, project, service } = await conflicted(); + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + // A further workspace edit reaches only the draft before the page goes away. + vi.mocked(api.saveProjectWorkspace).mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench( + stateWith([{ ...resolved.project, promptHistory: [{ negativePrompt: null, positivePrompt: 'after' }] }]) + ); + + const reloaded = await createService(captureAccountScope(), api, draftStore).loadWorkbench(); + + expect(reloaded.conflicts).toEqual([]); + expect(reloaded.state.projects[0]).toMatchObject({ + name: 'Edited here', + promptHistory: [{ positivePrompt: 'after' }], + }); + }); + + it('keeps this tab’s project edits when the server holds no workspace of its own', async () => { + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const project = createDraftProject([]); + await draftStore.stage({ + baseRevision: 1, + baseWorkspaceRevision: 1, + documentJson: JSON.stringify( + serializeProjectDocumentV3({ + ...project, + name: 'Edited here', + settings: { ...project.settings, useCpuNoise: !project.settings.useCpuNoise }, + }) + ), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 1, + projectId: project.id, + updatedAt: Date.parse(now), + writerToken: 'old-writer', + }); + api.records.set(project.id, { ...toSplitRecord(project), workspace: null, workspace_updated_at: null }); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api, draftStore); + expect((await service.loadWorkbench()).conflicts).toMatchObject([{ half: 'workspace' }]); + + const resolved = await service.resolveConflictUseServer(project.id); + service.acknowledgeProjectResolution(project.id); + if (resolved.status !== 'loaded') { + throw new Error('Expected the resolution to load.'); + } + await service.saveWorkbench(stateWith([resolved.project])); + + expect(resolved.project).toMatchObject({ name: 'Edited here', settings: project.settings }); + expect(api.records.get(project.id)).toMatchObject({ name: 'Edited here', revision: 2, workspace: { revision: 1 } }); + }); + + it('keeps the conflict when the merge cannot be staged', async () => { + const { api, draftStore, project, service } = await conflicted(); + vi.spyOn(draftStore, 'stage').mockResolvedValueOnce({ kind: 'fenced' }); + + await expect(service.resolveConflictUseServer(project.id)).rejects.toThrow(); + service.abortProjectResolution(project.id); + const saved = await service.saveWorkbench(stateWith([{ ...project, name: 'Edited here' }])); + + expect(saved.conflicts).toMatchObject([{ half: 'workspace' }]); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); +}); + +describe('answers lost to the network', () => { + it.each([ + [ + 'content', + 'updateProject', + (project: Project, edit: string): Project => ({ ...project, name: edit }), + { name: 'third', revision: 3 }, + ], + [ + 'workspace', + 'saveProjectWorkspace', + (project: Project, edit: string): Project => ({ + ...project, + promptHistory: [{ negativePrompt: null, positivePrompt: edit }], + }), + { revision: 1, workspace: { data: { promptHistory: [{ positivePrompt: 'third' }] }, revision: 3 } }, + ], + ] as const)( + 'knows an earlier %s save of its own when the edits after it are saved', + async (_half, method, edit, expected) => { + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toSplitRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(captureAccountScope(), api); + const loaded = (await service.loadWorkbench()).state.projects[0]!; + // The first save lands but its answer is lost; the next, of a further edit, is lost before it lands. + const save = vi.mocked(api[method]).getMockImplementation()! as (...args: unknown[]) => Promise; + vi.mocked(api[method]) + .mockImplementationOnce(async (...args: unknown[]) => { + await save(...args); + throw new TypeError('Failed to fetch'); + }) + .mockRejectedValueOnce(new TypeError('Failed to fetch')); + await service.saveWorkbench(stateWith([edit(loaded, 'first')])); + await service.saveWorkbench(stateWith([edit(loaded, 'second')])); + + const saved = await service.saveWorkbench(stateWith([edit(loaded, 'third')])); + + expect(saved.conflicts).toEqual([]); + expect(api.records.get(project.id)).toMatchObject(expected); + } + ); +}); + +describe('a project from before the split whose workflow repeats an item id', () => { + const withRepeatedNode = (base: Project): Project => { + const [entry, ...rest] = base.workflows.entries; + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + return { + ...base, + workflows: { + ...base.workflows, + entries: [{ ...entry!, document: { ...entry!.document, nodes: [node, node] } }, ...rest], + }, + } as unknown as Project; + }; + const open = async (record: ProjectWithWorkspaceDTO) => { + const api = createApi(); + api.records.set(record.project_id, record); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: record.project_id, + openProjectIds: [record.project_id], + }); + const service = createService(captureAccountScope(), api); + return { api, project: (await service.loadWorkbench()).state.projects[0]!, service }; + }; + + it('keeps saving whole rather than writing content no load would accept', async () => { + const project = withRepeatedNode(createDraftProject([])); + const { api, project: loaded, service } = await open(toRecord(project)); + // Nothing to split, so nothing to send until it changes. + await service.saveWorkbench(stateWith([loaded])); + expect(api.updateProject).not.toHaveBeenCalled(); + + const saved = await service.saveWorkbench(stateWith([{ ...loaded, name: 'Still whole' }])); + + expect(saved.error).toBeNull(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(api.records.get(project.id)).toMatchObject({ + data: { documentSchemaVersion: 3 }, + minimum_document_schema_version: 3, + name: 'Still whole', + revision: 2, + }); + }); + + it('is created whole, as an import or a recovered draft of one brings it back', async () => { + const api = createApi(); + const project = withRepeatedNode(createDraftProject([])); + const service = createService(captureAccountScope(), api); + + await service.saveWorkbench(stateWith([project])); + const saved = await service.saveWorkbench(stateWith([{ ...project, name: 'Saved again' }])); + + expect(saved.error).toBeNull(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + expect(vi.mocked(api.createProject).mock.calls[0]![0]).toMatchObject({ + data: { documentSchemaVersion: 3 }, + minimum_document_schema_version: 3, + }); + expect(api.records.get(project.id)).toMatchObject({ name: 'Saved again', revision: 2, workspace: null }); + }); + + it('refuses to save a split project once a workflow repeats an id, sending nothing', async () => { + const { api, project, service } = await open(toSplitRecord(createDraftProject([]))); + + const saved = await service.saveWorkbench(stateWith([withRepeatedNode({ ...project, name: 'Repeats' })])); + + expect(saved.error).toMatch(/repeats an item id/); + expect(saved.hasPendingChanges).toBe(true); + expect(api.updateProject).not.toHaveBeenCalled(); + expect(api.saveProjectWorkspace).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts index 2a07c51d02f..f6806361765 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts @@ -24,9 +24,14 @@ import type { ProjectRecordDTO, ProjectSummaryDTO, ProjectUpdateRequest, + ProjectWithWorkspaceDTO, + ProjectWorkspaceDTO, + ProjectWorkspaceSaveRequest, } from './api'; import type { ProjectDraft, + ProjectDraftBases, + ProjectDraftConflict, ProjectDraftCopyReservation, ProjectDraftRetargetCursor, ProjectDraftRetargetHandoff, @@ -47,15 +52,19 @@ import { deleteProject, getProject, getProjectCanvasSchemaCompatibilityRefusal, + getProjectDocumentSchemaRefusal, getProjectWriteSizeRefusal, isProjectConflictError, isProjectNotFoundError, listProjects, ProjectCreateAbsentError, + saveFirstProjectWorkspace, + saveProjectWorkspace, setClientStateValue, updateProject, } from './api'; import { recordProjectCover } from './covers'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { getUtf8ByteSize, getCopySourceProjectName, @@ -65,6 +74,7 @@ import { toConflictProjectDraft, toDirtyProjectDraft, toSchemaRefusedProjectDraft, + withBases, } from './draftStore'; import { createEditorSessionLivenessCheck, getEditorSession, isEditorSessionLive } from './editorSession'; import { createDeterministicProjectId } from './ids'; @@ -76,9 +86,11 @@ import { PROJECT_DOCUMENT_MAX_BYTES, PROJECT_DOCUMENT_SCHEMA_VERSION, serializeProjectDocumentV3Json, + type ProjectDocumentV3, } from './projectDocument'; import { assertProjectFlushed, ProjectFlushError } from './projectFlush'; -import { deserializeProjectDocument, deserializeProjectRecord } from './projectHydration'; +import { findRepeatedWorkflowItem, splitProjectDocument, toProjectCreation } from './projectHalves'; +import { deserializeProjectDocument, deserializeProjectRecord, isSplitProjectRecord } from './projectHydration'; import { acquireProjectMutationLock } from './projectLifecycleLocks'; import { fetchSessionBlobStrict, serializeSessionBlob, SESSION_STATE_KEY } from './session'; import { getOpenProject, reportProjectSync, reportProjectSyncEntry, resolveProjectSyncConflict } from './syncStore'; @@ -86,6 +98,8 @@ import { deleteWorkbenchDatabase } from './workbenchDatabase'; export interface ProjectConflictInfo { detectedAt: string; + /** For a revision conflict: whether the caller's workspace diverged rather than the project content. */ + half?: 'workspace'; kind: 'deleted' | 'revision'; projectId: string; serverRevision?: number; @@ -120,22 +134,41 @@ export interface SaveDraftAsNewInput { sourceProjectId: string; } -export type SaveDraftAsNew = (input: SaveDraftAsNewInput) => Promise; +/** Creates the copy, content then workspace; the record carries the workspace it saved. */ +export type SaveDraftAsNew = (input: SaveDraftAsNewInput) => Promise; + +/** How many unacknowledged bodies of each half a page remembers sending: a few retries' worth, each up to 32 MiB. */ +const SENT_HALVES_KEPT = 4; /** How long a failed upload-provenance creation answers later uploads before another attempt is made. */ const PROJECT_ENSURE_FAILURE_REUSE_MS = 5_000; -const saveDraftAsNew: SaveDraftAsNew = (input) => { - return createProjectSettled( - { - data: input.document, - minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, - name: input.name, - project_id: input.copyProjectId, - }, - input.owner - ); -}; +/** Save-as-copy through the persistence API: the copy's content, then its workspace. */ +const createSaveDraftAsNew = + (api: DurableProjectPersistenceApi): SaveDraftAsNew => + async (input) => { + const creation = toProjectCreation(input.document as unknown as ProjectDocumentV3); + const record = await api.createProject( + { + data: creation.data as unknown as Record, + minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + minimum_document_schema_version: creation.documentSchemaVersion, + name: input.name, + project_id: input.copyProjectId, + }, + input.owner + ); + assertAccountScopeCurrent(input.owner); + if (!creation.workspace) { + return { ...record, workspace: null }; + } + return saveFirstProjectWorkspace( + record, + creation.workspace as unknown as Record, + input.owner.signal, + api.saveProjectWorkspace + ); + }; const mapConcurrent = async ( items: readonly T[], @@ -237,8 +270,13 @@ export interface DurableProjectPersistenceApi { createProject(request: ProjectCreateRequest, owner: AccountScope): Promise; deleteProject(projectId: string, signal?: AbortSignal, boards?: DeleteProjectBoards): Promise; deleteSession(signal?: AbortSignal): Promise; - getProject(projectId: string, signal?: AbortSignal): Promise; + getProject(projectId: string, signal?: AbortSignal): Promise; listProjects(signal?: AbortSignal): Promise; + saveProjectWorkspace( + projectId: string, + request: ProjectWorkspaceSaveRequest, + signal?: AbortSignal + ): Promise; loadSession(signal?: AbortSignal): Promise; saveSession( state: WorkbenchState, @@ -256,6 +294,7 @@ const productionApi: DurableProjectPersistenceApi = { getProject, listProjects, loadSession: fetchSessionBlobStrict, + saveProjectWorkspace, saveSession: (state, editorSessionId, draftEditorSessionIds, signal) => setClientStateValue(SESSION_STATE_KEY, serializeSessionBlob(state, editorSessionId, draftEditorSessionIds), signal), updateProject, @@ -280,10 +319,25 @@ interface DurableSyncedPersistenceDependencies { writerToken?: string; } +/** + * What the server holds of a project, as this page last learned it. The content and the caller's workspace are + * acknowledged one after the other, so each has its own pushed copy and revision; `pushedDoc` is the whole document + * as last acknowledged with both. + */ interface SyncEntry { minimumCanvasSchemaVersion: number; + /** + * The whole document the server holds, or '' while that is not known to be any one document (one half acknowledged + * without the other). It is the cheap test for "nothing to save", so it must never name a document the server lacks. + */ pushedDoc: string; + /** The content's revision. */ revision: number; + /** The content the server holds; null while it holds the project whole (saved before the split). */ + pushedContent: string | null; + /** The caller's workspace the server holds; null while it holds none. */ + pushedWorkspace: string | null; + workspaceRevision: number | null; } interface LoadedDraft { @@ -309,36 +363,94 @@ const toSnapshot = (state: WorkbenchState, savedAt: string): HydratedWorkbenchSn version: 1, }); -const recordDocumentJson = (record: ProjectRecordDTO): string | null => { - const loaded = deserializeProjectRecord(record); - - return loaded.status === 'loaded' ? serializeProjectDocumentV3Json(loaded.project).documentJson : null; -}; - -const toProjectSummary = (record: ProjectRecordDTO): ProjectSummaryDTO => ({ +const toProjectSummary = (record: ProjectWithWorkspaceDTO): ProjectSummaryDTO => ({ board_id: record.board_id, created_at: record.created_at, minimum_canvas_schema_version: record.minimum_canvas_schema_version, + minimum_document_schema_version: record.minimum_document_schema_version, name: record.name, project_id: record.project_id, revision: record.revision, updated_at: record.updated_at, + workspace_updated_at: record.workspace?.updated_at ?? record.workspace_updated_at, }); -const recordRequiresDocumentUpgrade = (record: ProjectRecordDTO): boolean => - record.data.documentSchemaVersion !== PROJECT_DOCUMENT_SCHEMA_VERSION; +/** A project the server holds whole is split by its next save, whether or not anything changed. */ +/** A project the server holds whole is split by its next save, unless it must stay whole (`toProjectCreation`). */ +const recordRequiresDocumentUpgrade = (record: ProjectRecordDTO, project: Project): boolean => + !isSplitProjectRecord(record) && findRepeatedWorkflowItem(project.workflows.entries) === null; + +/** What the server holds of a read, which loaded as `project`. */ +const toSyncEntry = (record: ProjectWithWorkspaceDTO, project: Project): SyncEntry => { + const { workspace } = record; + const { document, documentJson } = serializeProjectDocumentV3Json(project); + const halves = splitProjectDocument(document); + const isSplit = isSplitProjectRecord(record); + return { + minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + pushedContent: isSplit ? halves.contentJson : null, + pushedDoc: documentJson, + pushedWorkspace: isSplit && workspace ? halves.workspaceJson : null, + revision: record.revision, + workspaceRevision: workspace?.revision ?? null, + }; +}; const toCanvasSchemaRefusal = (refusal: { maxCanvasSchemaVersion: number; minimumCanvasSchemaVersion: number; }): ProjectSchemaRefusal => ({ ...refusal, kind: 'canvas' }); -const toDocumentSchemaRefusal = (documentSchemaVersion: number): ProjectSchemaRefusal => ({ +const toDocumentSchemaRefusal = ( + documentSchemaVersion: number +): Extract => ({ documentSchemaVersion, kind: 'document', - maxDocumentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, + maxDocumentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, }); +/** The refusal a 412 carries, of either floor: a newer Invoke wrote the project. */ +const toSchemaRefusal = (error: unknown): ProjectSchemaRefusal | null => { + const canvas = getProjectCanvasSchemaCompatibilityRefusal(error); + if (canvas) { + return toCanvasSchemaRefusal(canvas); + } + const documentRefusal = getProjectDocumentSchemaRefusal(error); + return documentRefusal ? toDocumentSchemaRefusal(documentRefusal.minimumDocumentSchemaVersion) : null; +}; + +/** A project a newer Invoke wrote, as the workbench reports it when there is no local copy to open instead. */ +const toRefusedProject = ( + projectId: string, + projectName: string, + refusal: Extract +): RefusedWorkbenchProject => + refusal.kind === 'document' + ? { + projectId, + projectName, + raw: null, + refusal: { + raw: null, + scope: 'project-document', + status: 'unsupported-version', + version: refusal.documentSchemaVersion, + }, + source: 'project-document', + } + : { + projectId, + projectName, + raw: null, + refusal: { + raw: null, + scope: 'document', + status: 'unsupported-version', + version: refusal.minimumCanvasSchemaVersion, + }, + source: 'canvas', + }; + const loadDraftProject = (draft: ProjectDraft): Project | null => { try { const raw = JSON.parse(draft.documentJson) as unknown; @@ -389,7 +501,7 @@ export class ProjectDraftWriteRejectedError extends Error { export interface DurableSyncedWorkbenchPersistence { acknowledgeProjectResolution(projectId: string): void; abortProjectResolution(projectId: string): void; - adoptProjectRecord(record: ProjectRecordDTO): ProjectLoadResult; + adoptProjectRecord(record: ProjectWithWorkspaceDTO): ProjectLoadResult; clearWorkbench(): Promise; close(): void; deleteRecoverableDraft( @@ -509,7 +621,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } }; const writerToken = dependencies.writerToken ?? createUuid(); - const saveAsNew = dependencies.saveDraftAsNew ?? saveDraftAsNew; + const saveAsNew = dependencies.saveDraftAsNew ?? createSaveDraftAsNew(api); const deleteDatabase = dependencies.deleteDatabase ?? deleteWorkbenchDatabase; const databaseDeleteTimeoutMs = dependencies.databaseDeleteTimeoutMs ?? 5_000; const syncEntries = new Map(); @@ -533,6 +645,11 @@ export const createDurableSyncedWorkbenchPersistence = ( const serverDeletedProjectIds = new Set(); const pendingProjectIds = new Set(); const projectsRequiringDocumentUpgrade = new Set(); + /** + * The bodies this page sent of each half of a project since both were last acknowledged, newest last and at most + * `SENT_HALVES_KEPT`: any of them on the server is this page's own write, its answer lost. + */ + const sentHalves = new Map(); const pendingBoardAssignments = new Map(); const retargetedProjects = new Map< string, @@ -924,7 +1041,9 @@ export const createDurableSyncedWorkbenchPersistence = ( const serialized = serializeProjectDocumentV3Json(project); const entry = syncEntries.get(project.id); const existingDraft = volatileDrafts.get(project.id); - const needsDocumentUpgrade = projectsRequiringDocumentUpgrade.has(project.id); + // A conflicted or refused project pushes nothing until resolved, so its upgrade waits too. + const needsDocumentUpgrade = + projectsRequiringDocumentUpgrade.has(project.id) && !conflicts.has(project.id) && !schemaRefusals.has(project.id); const needsStage = needsDocumentUpgrade || (!entry && existingDraft?.documentJson !== serialized.documentJson) || @@ -958,6 +1077,7 @@ export const createDurableSyncedWorkbenchPersistence = ( baseMinimumCanvasSchemaVersion: entry?.minimumCanvasSchemaVersion ?? getProjectCanvasSchemaRequirement(serialized.document), baseRevision: entry?.revision ?? null, + baseWorkspaceRevision: entry?.workspaceRevision ?? null, documentJson: serialized.documentJson, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, editorSessionId: getDraftEditorSessionId(project.id, session.id), @@ -1007,7 +1127,11 @@ export const createDurableSyncedWorkbenchPersistence = ( ? toConflictProjectDraft( dirty, conflict.kind === 'revision' - ? { kind: 'revision', serverRevision: conflict.serverRevision! } + ? { + ...(conflict.half ? { half: conflict.half } : {}), + kind: 'revision', + serverRevision: conflict.serverRevision!, + } : { kind: 'deleted' } ) : refusal @@ -1029,29 +1153,28 @@ export const createDurableSyncedWorkbenchPersistence = ( return { editorSessionId: draftInput.editorSessionId, generation, serialized, store }; }; + /** Both halves are on the server as `acknowledged` says: the generation sent is acknowledged. */ const settleAck = async ( project: Project, sentGeneration: number, - record: ProjectRecordDTO, + acknowledged: Omit, + document: Record, documentJson: string, store: ProjectDraftStore, editorSessionId: string ): Promise => { - syncEntries.set(project.id, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: documentJson, - revision: record.revision, - }); + syncEntries.set(project.id, { ...acknowledged, pushedDoc: documentJson }); + sentHalves.delete(project.id); upsertProjectSummary( { id: project.id, - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + minimumCanvasSchemaVersion: acknowledged.minimumCanvasSchemaVersion, name: project.name, - revision: record.revision, + revision: acknowledged.revision, }, owner ); - recordProjectCover(project.id, selectCoverImageName(record.data), owner); + recordProjectCover(project.id, selectCoverImageName(document), owner); conflicts.delete(project.id); schemaRefusals.delete(project.id); projectsRequiringDocumentUpgrade.delete(project.id); @@ -1062,8 +1185,9 @@ export const createDurableSyncedWorkbenchPersistence = ( editorSessionId, writerToken, sentGeneration, - record.revision, - record.minimum_canvas_schema_version + acknowledged.revision, + acknowledged.minimumCanvasSchemaVersion, + acknowledged.workspaceRevision ); const journaled = journaledDocuments.get(project.id); if (journaled && journaled.generation <= sentGeneration) { @@ -1071,13 +1195,15 @@ export const createDurableSyncedWorkbenchPersistence = ( } else if ( journaled && journaled.editorSessionId === editorSessionId && - journaled.baseRevision !== record.revision + (journaled.baseRevision !== acknowledged.revision || + (journaled.baseWorkspaceRevision ?? null) !== acknowledged.workspaceRevision) ) { // A newer journaled document builds on the acknowledged one: rebase it, as a newer staged draft is rebased. const rebased = { ...journaled, - baseMinimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - baseRevision: record.revision, + baseMinimumCanvasSchemaVersion: acknowledged.minimumCanvasSchemaVersion, + baseRevision: acknowledged.revision, + baseWorkspaceRevision: acknowledged.workspaceRevision, }; const write = store.journalBeforeUnload({ entries: [rebased], retired: [] }); if (write.kind === 'started' && (await write.written) && journaledDocuments.get(project.id) === journaled) { @@ -1096,7 +1222,13 @@ export const createDurableSyncedWorkbenchPersistence = ( const volatile = volatileDrafts.get(project.id); if (volatile) { if (volatile.generation > sentGeneration) { - volatileDrafts.set(project.id, toDirtyProjectDraft(volatile, { baseRevision: record.revision })); + volatileDrafts.set( + project.id, + toDirtyProjectDraft(volatile, { + baseRevision: acknowledged.revision, + baseWorkspaceRevision: acknowledged.workspaceRevision, + }) + ); pendingProjectIds.add(project.id); hasPending = true; } else { @@ -1105,10 +1237,41 @@ export const createDurableSyncedWorkbenchPersistence = ( } }; + /** + * A recovered draft against what the server holds: null when the draft builds on it, else the conflict. Each half is + * judged apart: a half equal to the server's needs no base (a page can save the content and be gone before the + * workspace), and a changed half conflicts only if the server moved it past the draft's base. + */ + const findDraftDivergence = ( + draft: ProjectDraft, + serverProject: Project, + record: ProjectWithWorkspaceDTO + ): ProjectDraftConflict | null => { + // Built on what the server holds of both halves: nothing to compare, which on a large document is costly. + if ( + draft.baseRevision === record.revision && + (draft.baseWorkspaceRevision ?? null) === (record.workspace?.revision ?? null) + ) { + return null; + } + const ours = splitProjectDocument(JSON.parse(draft.documentJson) as ProjectDocumentV3); + const theirs = splitProjectDocument(serializeProjectDocumentV3Json(serverProject).document); + if (ours.contentJson !== theirs.contentJson && draft.baseRevision !== record.revision) { + return { kind: 'revision', serverRevision: record.revision }; + } + if ( + ours.workspaceJson !== theirs.workspaceJson && + (draft.baseWorkspaceRevision ?? null) !== (record.workspace?.revision ?? null) + ) { + return { half: 'workspace', kind: 'revision', serverRevision: record.revision }; + } + return null; + }; + const markConflict = async ( projectId: string, generation: number, - conflict: { kind: 'deleted' } | { kind: 'revision'; serverRevision: number }, + conflict: ProjectDraftConflict, store: ProjectDraftStore, editorSessionId: string ): Promise => { @@ -1117,6 +1280,7 @@ export const createDurableSyncedWorkbenchPersistence = ( kind: conflict.kind, projectId, ...(conflict.kind === 'revision' ? { serverRevision: conflict.serverRevision } : {}), + ...(conflict.kind === 'revision' && conflict.half ? { half: conflict.half } : {}), }; conflicts.set(projectId, info); pendingProjectIds.add(projectId); @@ -1180,40 +1344,94 @@ export const createDurableSyncedWorkbenchPersistence = ( return { documentJson, kind: 'conflicted' }; } if (entry?.pushedDoc === documentJson && !projectsRequiringDocumentUpgrade.has(project.id)) { - await settleAck( - project, - generation, - { - board_id: '', - created_at: '', - data: document, - minimum_canvas_schema_version: entry.minimumCanvasSchemaVersion, - name: project.name, - project_id: project.id, - revision: entry.revision, - updated_at: '', - }, - documentJson, - store, - editorSessionId - ); + await settleAck(project, generation, entry, document, documentJson, store, editorSessionId); return { documentJson, kind: 'acknowledged' }; } - try { - if (!entry) { + const halves = splitProjectDocument(document); + // Split content that repeats an item id would be refused by every later load, so such a project, which only a + // build before the split could have written, is created whole and keeps saving whole (see `toProjectCreation`). + const keepsWhole = findRepeatedWorkflowItem(halves.content.workflows.entries) !== null; + if (keepsWhole && entry && entry.pushedContent !== null) { + hasPending = true; + throw new Error( + 'A workflow in this project now repeats an item id, so its changes cannot be saved. Undo the last workflow change to keep saving.' + ); + } + /** + * The whole document the server holds once `entry` is, only when that is this one: with one half acknowledged + * and not the other, it holds neither this document nor the last one, and the shortcut that trusts `pushedDoc` + * must not take either for it. + */ + const acknowledgedDocument = (entry: Omit): string => + entry.pushedContent === halves.contentJson && entry.pushedWorkspace === halves.workspaceJson ? documentJson : ''; + const settleRefusal = async (refusal: ProjectSchemaRefusal): Promise => { + schemaRefusals.set(project.id, refusal); + hasPending = true; + await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); + const volatile = volatileDrafts.get(project.id); + if (volatile) { + volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); + } + return { documentJson, kind: 'schema-refused', refusal }; + }; + const conflicted = async (conflict: ProjectDraftConflict): Promise => { + await markConflict(project.id, generation, conflict, store, editorSessionId); + return { documentJson, kind: 'conflicted' }; + }; + + /** Records what the server acknowledged of either half, as soon as it has, so a retry sends only what is owed. */ + const record = (next: Omit): Omit => { + syncEntries.set(project.id, { ...next, pushedDoc: acknowledgedDocument(next) }); + return next; + }; + /** What this page sent of a half, kept until acknowledged: a save that landed with its answer lost is its own. */ + const noteSent = (half: 'content' | 'workspace', json: string): void => { + const sent = sentHalves.get(project.id) ?? { content: [], workspace: [] }; + sentHalves.set(project.id, { + ...sent, + [half]: [...sent[half].filter((body) => body !== json), json].slice(-SENT_HALVES_KEPT), + }); + }; + /** + * The lineage rebased onto the content just acknowledged, before the workspace is sent: should that save fail or + * the page go, recovery builds on the content the server now holds rather than conflicting with its own save. + */ + const rebaseLineage = async (bases: ProjectDraftBases): Promise => { + await store.rebaseDraft(project.id, editorSessionId, writerToken, generation, bases); + const volatile = volatileDrafts.get(project.id); + if (volatile) { + volatileDrafts.set(project.id, withBases(volatile, bases)); + } + const journaled = journaledDocuments.get(project.id); + if (journaled && journaled.editorSessionId === editorSessionId) { + const rebased = { ...journaled, ...bases }; + const write = store.journalBeforeUnload({ entries: [rebased], retired: [] }); + if (write.kind === 'started' && (await write.written) && journaledDocuments.get(project.id) === journaled) { + journaledDocuments.set(project.id, rebased); + } + } + }; + + /** + * Content first, then the workspace, one after the other: the server takes one write per account at a time, and + * the content's save is what raises the document floor, so a workspace never sits beside a project an older page + * could still save whole. + */ + const pushHalves = async (base: SyncEntry | undefined): Promise | 'conflicted'> => { + if (keepsWhole && !base) { const created = await api.createProject( { - data: document, + data: document as unknown as Record, minimum_canvas_schema_version: getProjectCanvasSchemaRequirement(document), + minimum_document_schema_version: PROJECT_DOCUMENT_SCHEMA_VERSION, name: project.name, project_id: project.id, }, owner ); assertOwner(); - const serverJson = JSON.stringify(created.data); - if (serverJson !== documentJson) { + if (JSON.stringify(created.data) !== documentJson) { await markConflict( project.id, generation, @@ -1221,145 +1439,226 @@ export const createDurableSyncedWorkbenchPersistence = ( store, editorSessionId ); - return { documentJson, kind: 'conflicted' }; + return 'conflicted'; } - await settleAck(project, generation, created, documentJson, store, editorSessionId); pendingBoardAssignments.set(project.id, { boardId: created.board_id, projectId: project.id }); - return { documentJson, kind: 'acknowledged' }; - } - - const raisedFloor = getRaisedCanvasSchemaFloor(document, entry.minimumCanvasSchemaVersion); - const updated = await api.updateProject( - project.id, - { - data: document, - expected_revision: entry.revision, - ...(raisedFloor === undefined ? {} : { minimum_canvas_schema_version: raisedFloor }), - name: project.name, - }, - owner.signal - ); - assertOwner(); - await settleAck(project, generation, updated, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } catch (error) { - assertOwner(); - const refusalError = error instanceof ProjectCreateAbsentError ? error.cause : error; - const sizeRefusal = getProjectWriteSizeRefusal(refusalError); - if (sizeRefusal) { - hasPending = true; - throw new ProjectDocumentTooLargeError(sizeRefusal.actualBytes, sizeRefusal.maxBytes); - } - const schemaRefusal = getProjectCanvasSchemaCompatibilityRefusal(refusalError); - if (schemaRefusal) { - const refusal = toCanvasSchemaRefusal(schemaRefusal); - schemaRefusals.set(project.id, refusal); - hasPending = true; - await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); - const volatile = volatileDrafts.get(project.id); - if (volatile) { - volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); - } - return { documentJson, kind: 'schema-refused', refusal }; - } - if (error instanceof ProjectCreateAbsentError) { - throw error.cause; + return record({ + minimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + pushedContent: null, + pushedWorkspace: null, + revision: created.revision, + workspaceRevision: null, + }); } - if (isStatus(error, 404)) { - if (deletedProjectIds.has(project.id)) { - return { documentJson, kind: 'superseded' }; - } - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; + if (keepsWhole && base) { + const updated = await api.updateProject( + project.id, + { + data: document as unknown as Record, + expected_revision: base.revision, + name: project.name, + }, + owner.signal + ); + assertOwner(); + // Never splitting, it needs no upgrade: the shortcut for an unchanged document applies again. + projectsRequiringDocumentUpgrade.delete(project.id); + return record({ + ...base, + minimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + pushedContent: null, + pushedWorkspace: null, + revision: updated.revision, + }); } - if (isStatus(error, 409)) { - try { - const current = await api.getProject(project.id, owner.signal); - assertOwner(); - const currentJson = recordDocumentJson(current); - if (currentJson === documentJson) { - await settleAck(project, generation, current, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } - if (entry && currentJson === entry.pushedDoc) { - syncEntries.set(project.id, { - minimumCanvasSchemaVersion: current.minimum_canvas_schema_version, - pushedDoc: entry.pushedDoc, - revision: current.revision, - }); - const retryRaisedFloor = getRaisedCanvasSchemaFloor(document, current.minimum_canvas_schema_version); - const retried = await api.updateProject( - project.id, - { - data: document, - expected_revision: current.revision, - ...(retryRaisedFloor === undefined ? {} : { minimum_canvas_schema_version: retryRaisedFloor }), - name: project.name, - }, - owner.signal - ); - assertOwner(); - await settleAck(project, generation, retried, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } + let acknowledged: Omit; + const workspaceOwed = base?.pushedWorkspace !== halves.workspaceJson; + if (!base) { + noteSent('content', halves.contentJson); + const created = await api.createProject( + { + data: halves.content as unknown as Record, + minimum_canvas_schema_version: getProjectCanvasSchemaRequirement(document), + minimum_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + name: project.name, + project_id: project.id, + }, + owner + ); + assertOwner(); + if (JSON.stringify(created.data) !== halves.contentJson) { await markConflict( project.id, generation, - { kind: 'revision', serverRevision: current.revision }, + { kind: 'revision', serverRevision: created.revision }, store, editorSessionId ); + return 'conflicted'; + } + pendingBoardAssignments.set(project.id, { boardId: created.board_id, projectId: project.id }); + acknowledged = record({ + minimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + pushedContent: halves.contentJson, + pushedWorkspace: null, + revision: created.revision, + workspaceRevision: null, + }); + await rebaseLineage({ + baseMinimumCanvasSchemaVersion: created.minimum_canvas_schema_version, + baseRevision: created.revision, + }); + } else if (projectsRequiringDocumentUpgrade.has(project.id) || base.pushedContent !== halves.contentJson) { + const raisedFloor = getRaisedCanvasSchemaFloor(document, base.minimumCanvasSchemaVersion); + noteSent('content', halves.contentJson); + const updated = await api.updateProject( + project.id, + { + data: halves.content as unknown as Record, + expected_revision: base.revision, + ...(raisedFloor === undefined ? {} : { minimum_canvas_schema_version: raisedFloor }), + // Idempotent on a split project; splits one the server holds whole. + minimum_document_schema_version: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + name: project.name, + }, + owner.signal + ); + assertOwner(); + projectsRequiringDocumentUpgrade.delete(project.id); + acknowledged = record({ + ...base, + minimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + pushedContent: halves.contentJson, + revision: updated.revision, + }); + if (workspaceOwed) { + await rebaseLineage({ + baseMinimumCanvasSchemaVersion: updated.minimum_canvas_schema_version, + baseRevision: updated.revision, + }); + } + } else { + acknowledged = base; + } + if (acknowledged.pushedWorkspace !== halves.workspaceJson) { + noteSent('workspace', halves.workspaceJson); + const saved = await api.saveProjectWorkspace( + project.id, + { + data: halves.workspace as unknown as Record, + expected_revision: acknowledged.workspaceRevision, + }, + owner.signal + ); + assertOwner(); + acknowledged = record({ + ...acknowledged, + pushedWorkspace: halves.workspaceJson, + workspaceRevision: saved.revision, + }); + } + return acknowledged; + }; + + /** + * After a revision conflict: what the server holds of each half. A half it holds as ours is acknowledged; a half + * it still holds as this page last saw it was moved by a save of the same bytes, and is retried at its revision; + * any other half diverged. + */ + const reconcile = async (): Promise< + | { kind: 'acknowledged' | 'retry'; entry: Omit } + | { kind: 'diverged'; half?: 'workspace'; serverRevision: number } + > => { + const latest = await api.getProject(project.id, owner.signal); + assertOwner(); + const loaded = deserializeProjectRecord(latest); + if (loaded.status !== 'loaded') { + return { kind: 'diverged', serverRevision: latest.revision }; + } + const server = serializeProjectDocumentV3Json(loaded.project); + const serverHalves = splitProjectDocument(server.document); + const split = isSplitProjectRecord(latest); + const base = syncEntries.get(project.id); + const contentHeld = split && serverHalves.contentJson === halves.contentJson; + const sent = sentHalves.get(project.id); + // Unmoved: still what this page last had acknowledged, or what it sent and never heard back about. + const contentUnmoved = split + ? base?.pushedContent === serverHalves.contentJson || sent?.content.includes(serverHalves.contentJson) === true + : base !== undefined && base.pushedContent === null && base.pushedDoc === server.documentJson; + const workspaceHeld = Boolean(latest.workspace) && serverHalves.workspaceJson === halves.workspaceJson; + const workspaceUnmoved = latest.workspace + ? base?.pushedWorkspace === serverHalves.workspaceJson || + sent?.workspace.includes(serverHalves.workspaceJson) === true + : (base?.workspaceRevision ?? null) === null; + if (!(contentHeld || contentUnmoved)) { + return { kind: 'diverged', serverRevision: latest.revision }; + } + if (!(workspaceHeld || workspaceUnmoved)) { + return { half: 'workspace', kind: 'diverged', serverRevision: latest.revision }; + } + const entry = { + minimumCanvasSchemaVersion: latest.minimum_canvas_schema_version, + pushedContent: split ? serverHalves.contentJson : null, + pushedWorkspace: latest.workspace ? serverHalves.workspaceJson : null, + revision: latest.revision, + workspaceRevision: latest.workspace?.revision ?? null, + }; + return { entry, kind: contentHeld && workspaceHeld ? 'acknowledged' : 'retry' }; + }; + + let base = entry; + // One retry: a second conflict means someone is saving this project now, which is a divergence to resolve. + for (let attempt = 0; ; attempt += 1) { + try { + const acknowledged = await pushHalves(base); + if (acknowledged === 'conflicted') { return { documentJson, kind: 'conflicted' }; + } + await settleAck(project, generation, acknowledged, document, documentJson, store, editorSessionId); + return { documentJson, kind: 'acknowledged' }; + } catch (error) { + assertOwner(); + const refusalError = error instanceof ProjectCreateAbsentError ? error.cause : error; + const sizeRefusal = getProjectWriteSizeRefusal(refusalError); + if (sizeRefusal) { + hasPending = true; + throw new ProjectDocumentTooLargeError(sizeRefusal.actualBytes, sizeRefusal.maxBytes); + } + const schemaRefusal = toSchemaRefusal(refusalError); + if (schemaRefusal) { + return settleRefusal(schemaRefusal); + } + if (error instanceof ProjectCreateAbsentError) { + throw error.cause; + } + if (isStatus(error, 404)) { + if (deletedProjectIds.has(project.id)) { + return { documentJson, kind: 'superseded' }; + } + return conflicted({ kind: 'deleted' }); + } + if (!isStatus(error, 409)) { + if (isDeterministicClientError(error)) { + throw error; + } + hasPending = true; + return { documentJson, kind: 'unsynced' }; + } + let settled: Awaited>; + try { + settled = await reconcile(); } catch (readError) { assertOwner(); const retrySizeRefusal = getProjectWriteSizeRefusal(readError); if (retrySizeRefusal) { throw new ProjectDocumentTooLargeError(retrySizeRefusal.actualBytes, retrySizeRefusal.maxBytes); } - const retrySchemaRefusal = getProjectCanvasSchemaCompatibilityRefusal(readError); - if (retrySchemaRefusal) { - const refusal = toCanvasSchemaRefusal(retrySchemaRefusal); - schemaRefusals.set(project.id, refusal); - await store.settleSchemaRefusal(project.id, editorSessionId, writerToken, generation, refusal); - const volatile = volatileDrafts.get(project.id); - if (volatile) { - volatileDrafts.set(project.id, toSchemaRefusedProjectDraft(volatile, refusal)); - } - return { documentJson, kind: 'schema-refused', refusal }; + const readRefusal = toSchemaRefusal(readError); + if (readRefusal) { + return settleRefusal(readRefusal); } if (isStatus(readError, 404)) { - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; - } - if (isStatus(readError, 409)) { - let latest: ProjectRecordDTO; - try { - latest = await api.getProject(project.id, owner.signal); - } catch (latestError) { - assertOwner(); - if (isStatus(latestError, 404)) { - await markConflict(project.id, generation, { kind: 'deleted' }, store, editorSessionId); - return { documentJson, kind: 'conflicted' }; - } - if (isDeterministicClientError(latestError)) { - throw latestError; - } - hasPending = true; - return { documentJson, kind: 'unsynced' }; - } - assertOwner(); - if (recordDocumentJson(latest) === documentJson) { - await settleAck(project, generation, latest, documentJson, store, editorSessionId); - return { documentJson, kind: 'acknowledged' }; - } - await markConflict( - project.id, - generation, - { kind: 'revision', serverRevision: latest.revision }, - store, - editorSessionId - ); - return { documentJson, kind: 'conflicted' }; + return conflicted({ kind: 'deleted' }); } if (isDeterministicClientError(readError)) { throw readError; @@ -1367,12 +1666,20 @@ export const createDurableSyncedWorkbenchPersistence = ( hasPending = true; return { documentJson, kind: 'unsynced' }; } + if (settled.kind === 'acknowledged') { + await settleAck(project, generation, settled.entry, document, documentJson, store, editorSessionId); + return { documentJson, kind: 'acknowledged' }; + } + if (settled.kind === 'diverged' || attempt > 0) { + return conflicted({ + ...(settled.kind === 'diverged' && settled.half ? { half: settled.half } : {}), + kind: 'revision', + serverRevision: settled.kind === 'diverged' ? settled.serverRevision : settled.entry.revision, + }); + } + base = { ...settled.entry, pushedDoc: acknowledgedDocument(settled.entry) }; + syncEntries.set(project.id, base); } - if (isDeterministicClientError(error)) { - throw error; - } - hasPending = true; - return { documentJson, kind: 'unsynced' }; } }; @@ -1443,6 +1750,7 @@ export const createDurableSyncedWorkbenchPersistence = ( conflict: { detectedAt: conflict.detectedAt, kind: conflict.kind, + ...(conflict.half ? { half: conflict.half } : {}), ...(conflict.serverRevision === undefined ? {} : { serverRevision: conflict.serverRevision }), }, } @@ -1624,6 +1932,94 @@ export const createDurableSyncedWorkbenchPersistence = ( await settleJournaledDocument(projectId, store); }; + /** + * Resolves a workspace conflict with the server's workspace, keeping this tab's project content: only the workspace + * diverged, and the content builds on what the server holds, so it is still this tab's to save. Content the server + * moved since is a conflict of its own, reported as one rather than overwritten. The draft is rewritten in place, + * never deleted first, so a crash at any point leaves either the conflict or the merge; a failure restores the + * conflict. + */ + const useServerWorkspace = async ( + projectId: string, + record: ProjectWithWorkspaceDTO, + serverProject: Project + ): Promise => { + const { draft, editorSessionId, store } = await requireDraft(projectId); + // This tab's latest: what it last handed a save, which may not be staged yet, or else its draft. + const local = lastKnownState?.projects.find((candidate) => candidate.id === projectId) ?? loadDraftProject(draft); + if (!local) { + throw new Error('The local project could not be combined with the saved workspace.'); + } + const ours = splitProjectDocument(serializeProjectDocumentV3Json(local).document); + const theirs = splitProjectDocument(serializeProjectDocumentV3Json(serverProject).document); + if (ours.contentJson !== theirs.contentJson && draft.baseRevision !== record.revision) { + await markConflict( + projectId, + draft.generation, + { kind: 'revision', serverRevision: record.revision }, + store, + editorSessionId + ); + reportSingleProjectSync(projectId); + throw new Error('The project changed elsewhere as well. Choose again which version to keep.'); + } + const merged = deserializeProjectRecord({ + ...record, + data: ours.content as unknown as Record, + }); + if (merged.status !== 'loaded') { + throw new Error('The local project could not be combined with the saved workspace.'); + } + const previousConflict = conflicts.get(projectId); + const previousEntry = syncEntries.get(projectId); + const resumed = await store.resumeDraft(projectId, editorSessionId, writerToken, draft.generation); + if (resumed.kind !== 'marked') { + throw new Error('The local project draft could not be resumed.'); + } + try { + syncEntries.set(projectId, toSyncEntry(record, serverProject)); + conflicts.delete(projectId); + schemaRefusals.delete(projectId); + volatileDrafts.set(projectId, resumed.draft); + const staged = await stageProject(merged.project); + const rebased = await store.rebaseDraft(projectId, editorSessionId, writerToken, staged.generation, { + baseMinimumCanvasSchemaVersion: record.minimum_canvas_schema_version, + baseRevision: record.revision, + baseWorkspaceRevision: record.workspace?.revision ?? null, + }); + if (rebased.kind !== 'rebased') { + throw new Error('The local project draft could not be rebased onto the saved workspace.'); + } + } catch (error) { + if (previousEntry) { + syncEntries.set(projectId, previousEntry); + } + if (previousConflict?.kind === 'revision') { + await markConflict( + projectId, + draft.generation, + { + ...(previousConflict.half ? { half: previousConflict.half } : {}), + kind: 'revision', + serverRevision: previousConflict.serverRevision ?? record.revision, + }, + store, + editorSessionId + ); + } + reportSingleProjectSync(projectId); + throw error; + } + pendingProjectIds.add(projectId); + hasPending = true; + resolveProjectSyncConflict(projectId, { projectId, revision: record.revision }, hasPending); + projectResolutionFences.set(projectId, { kind: 'replace', project: merged.project }); + if (lastKnownState) { + lastKnownState = applyProjectResolutionFences(lastKnownState); + } + return merged; + }; + const alignLoadedDraftWithProject = async ( draft: ProjectDraft, project: Project, @@ -1705,15 +2101,15 @@ export const createDurableSyncedWorkbenchPersistence = ( } } - let record: ProjectRecordDTO; + let record: ProjectWithWorkspaceDTO; try { record = await api.getProject(projectId, owner.signal); assertOwner(); } catch (error) { assertOwner(); - const compatibility = getProjectCanvasSchemaCompatibilityRefusal(error); - if (compatibility && loadedDraft) { - const refusal = toCanvasSchemaRefusal(compatibility); + const readRefusal = toSchemaRefusal(error); + if (readRefusal && loadedDraft) { + const refusal = readRefusal; loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, loadedDraft.project, store); schemaRefusals.set(projectId, refusal); await store.settleSchemaRefusal( @@ -1746,22 +2142,8 @@ export const createDurableSyncedWorkbenchPersistence = ( reportOwnedProjectSync(); return { project: loadedDraft.project, status: 'loaded' }; } - if (compatibility) { - return { - refused: { - projectId, - projectName, - raw: null, - refusal: { - raw: null, - scope: 'document', - status: 'unsupported-version', - version: compatibility.minimumCanvasSchemaVersion, - }, - source: 'canvas', - }, - status: 'refused', - }; + if (readRefusal?.kind === 'canvas' || readRefusal?.kind === 'document') { + return { refused: toRefusedProject(projectId, projectName, readRefusal), status: 'refused' }; } return { status: 'unavailable' }; } @@ -1801,13 +2183,11 @@ export const createDurableSyncedWorkbenchPersistence = ( return { project: localProject, status: 'loaded' }; } - const serverJson = serializeProjectDocumentV3Json(result.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serverJson, - revision: record.revision, - }); - if (recordRequiresDocumentUpgrade(record)) { + const serverEntry = toSyncEntry(record, result.project); + syncEntries.set(projectId, serverEntry); + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(projectId); + if (recordRequiresDocumentUpgrade(record, result.project)) { projectsRequiringDocumentUpgrade.add(projectId); pendingProjectIds.add(projectId); hasPending = true; @@ -1819,39 +2199,36 @@ export const createDurableSyncedWorkbenchPersistence = ( const localProject = withAuthoritativeProjectBoard(loadedDraft.project, record.board_id); loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, localProject, store); - if (loadedDraft.draft.documentJson === serverJson) { + if (loadedDraft.draft.documentJson === serverEntry.pushedDoc) { await store.settleAcknowledgement( projectId, loadedDraft.draft.editorSessionId, writerToken, loadedDraft.draft.generation, record.revision, - record.minimum_canvas_schema_version + record.minimum_canvas_schema_version, + serverEntry.workspaceRevision ); volatileDrafts.delete(projectId); pendingProjectIds.delete(projectId); reportOwnedProjectSync(); return result; } + const divergence = + loadedDraft.draft.state === 'conflict' + ? loadedDraft.draft.conflict + : findDraftDivergence(loadedDraft.draft, result.project, record); if ( loadedDraft.draft.state === 'schema-refused' && loadedDraft.draft.refusal.kind === 'canvas' && !isCanvasSchemaVersionSupported(loadedDraft.draft.refusal.minimumCanvasSchemaVersion) ) { schemaRefusals.set(projectId, loadedDraft.draft.refusal); - } else if (loadedDraft.draft.state === 'conflict' || loadedDraft.draft.baseRevision !== record.revision) { - await markConflict( - projectId, - loadedDraft.draft.generation, - loadedDraft.draft.state === 'conflict' - ? loadedDraft.draft.conflict - : { kind: 'revision', serverRevision: record.revision }, - store, - loadedDraft.draft.editorSessionId - ); + } else if (divergence) { + await markConflict(projectId, loadedDraft.draft.generation, divergence, store, loadedDraft.draft.editorSessionId); } else { if (loadedDraft.draft.state === 'schema-refused') { - await store.resumeSchemaRefused( + await store.resumeDraft( projectId, loadedDraft.draft.editorSessionId, writerToken, @@ -1879,14 +2256,12 @@ export const createDurableSyncedWorkbenchPersistence = ( } const result = deserializeProjectRecord(record); if (result.status === 'loaded') { - syncEntries.set(record.project_id, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serializeProjectDocumentV3Json(result.project).documentJson, - revision: record.revision, - }); + syncEntries.set(record.project_id, toSyncEntry(record, result.project)); conflicts.delete(record.project_id); schemaRefusals.delete(record.project_id); - if (recordRequiresDocumentUpgrade(record)) { + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(record.project_id); + if (recordRequiresDocumentUpgrade(record, result.project)) { projectsRequiringDocumentUpgrade.add(record.project_id); pendingProjectIds.add(record.project_id); } @@ -1931,6 +2306,7 @@ export const createDurableSyncedWorkbenchPersistence = ( seedProjectLibrary([], owner); } syncEntries.clear(); + sentHalves.clear(); conflicts.clear(); schemaRefusals.clear(); pendingProjectIds.clear(); @@ -2038,6 +2414,7 @@ export const createDurableSyncedWorkbenchPersistence = ( volatileDrafts.delete(resolvedProjectId); draftEditorSessionIds.delete(resolvedProjectId); syncEntries.delete(resolvedProjectId); + sentHalves.delete(resolvedProjectId); conflicts.delete(resolvedProjectId); projectsRequiringDocumentUpgrade.delete(resolvedProjectId); pendingProjectIds.delete(resolvedProjectId); @@ -2180,6 +2557,7 @@ export const createDurableSyncedWorkbenchPersistence = ( baseMinimumCanvasSchemaVersion: entry?.minimumCanvasSchemaVersion ?? getProjectCanvasSchemaRequirement(serialized.document), baseRevision: entry?.revision ?? null, + baseWorkspaceRevision: entry?.workspaceRevision ?? null, documentByteSize: serialized.byteSize, documentJson: serialized.documentJson, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, @@ -2307,6 +2685,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } assertOwner(); syncEntries.clear(); + sentHalves.clear(); conflicts.clear(); schemaRefusals.clear(); projectsRequiringDocumentUpgrade.clear(); @@ -2364,7 +2743,7 @@ export const createDurableSyncedWorkbenchPersistence = ( ]); const draftCandidatesByProject = groupDraftCandidatesByProject(draftCandidates); const summaryById = new Map(summaries.map((summary) => [summary.project_id, summary])); - const handoffRecordsById = new Map(); + const handoffRecordsById = new Map(); const handoffTargetIds = new Set(); for (const handoff of retargetHandoffs) { if (!summaryById.has(handoff.targetProjectId)) { @@ -2477,11 +2856,20 @@ export const createDurableSyncedWorkbenchPersistence = ( volatileDrafts.set(projectId, draft); } const summary = summaryById.get(projectId); - const summaryIsIncompatible = Boolean( - summary && !isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version) - ); - let record: ProjectRecordDTO | null = null; - if (summary && isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version)) { + // The listing already says what a read would refuse: neither floor is worth a request to learn again. + const summaryRefusal: Parameters[2] | null = !summary + ? null + : !isCanvasSchemaVersionSupported(summary.minimum_canvas_schema_version) + ? { + kind: 'canvas', + maxCanvasSchemaVersion: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, + minimumCanvasSchemaVersion: summary.minimum_canvas_schema_version, + } + : summary.minimum_document_schema_version > PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION + ? toDocumentSchemaRefusal(summary.minimum_document_schema_version) + : null; + let record: ProjectWithWorkspaceDTO | null = null; + if (summary && !summaryRefusal) { try { record = handoffRecordsById.get(projectId) ?? (await api.getProject(projectId, owner.signal)); } catch (error) { @@ -2489,19 +2877,8 @@ export const createDurableSyncedWorkbenchPersistence = ( throw new WorkbenchBackendUnavailableError(error); } } - } else if (summary) { - refusedProjects.push({ - projectId, - projectName: summary.name, - raw: null, - refusal: { - raw: null, - scope: 'document', - status: 'unsupported-version', - version: summary.minimum_canvas_schema_version, - }, - source: 'canvas', - }); + } else if (summary && summaryRefusal) { + refusedProjects.push(toRefusedProject(projectId, summary.name, summaryRefusal)); } let loadedDraft: LoadedDraft | null = null; @@ -2532,13 +2909,8 @@ export const createDurableSyncedWorkbenchPersistence = ( return; } loadedDraft.draft = await alignLoadedDraftWithProject(loadedDraft.draft, loadedDraft.project, store); - if (summaryIsIncompatible) { - const minimumCanvasSchemaVersion = summary!.minimum_canvas_schema_version; - const refusal: ProjectSchemaRefusal = { - kind: 'canvas', - maxCanvasSchemaVersion: MAX_SUPPORTED_CANVAS_SCHEMA_VERSION, - minimumCanvasSchemaVersion, - }; + if (summaryRefusal) { + const refusal = summaryRefusal; schemaRefusals.set(projectId, refusal); await store.settleSchemaRefusal( projectId, @@ -2600,13 +2972,12 @@ export const createDurableSyncedWorkbenchPersistence = ( if (serverLoad.status !== 'loaded') { return; } - const serverJson = serializeProjectDocumentV3Json(serverLoad.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: serverJson, - revision: record.revision, - }); - if (recordRequiresDocumentUpgrade(record)) { + const serverEntry = toSyncEntry(record, serverLoad.project); + const serverJson = serverEntry.pushedDoc; + syncEntries.set(projectId, serverEntry); + // Another page may have split it since this one learned it was whole. + projectsRequiringDocumentUpgrade.delete(projectId); + if (recordRequiresDocumentUpgrade(record, serverLoad.project)) { projectsRequiringDocumentUpgrade.add(projectId); pendingProjectIds.add(projectId); hasPending = true; @@ -2624,7 +2995,8 @@ export const createDurableSyncedWorkbenchPersistence = ( writerToken, loadedDraft.draft.generation, record.revision, - record.minimum_canvas_schema_version + record.minimum_canvas_schema_version, + serverEntry.workspaceRevision ); pendingProjectIds.delete(projectId); loadedProjects.set(projectId, serverLoad.project); @@ -2632,26 +3004,48 @@ export const createDurableSyncedWorkbenchPersistence = ( } // The journal replaced a staged draft whose save reached the server without its answer reaching the page: // the server holds exactly that document, so the journal is its successor, as if the answer had arrived. + // Judged half by half: of a split save, one half may have landed and not the other. const replaced = journalReplacedDrafts.get(projectId)?.replacedDraft; if ( - replaced?.documentJson === serverJson && + replaced && journalReplacedDrafts.get(projectId)?.editorSessionId === loadedDraft.draft.editorSessionId && - loadedDraft.draft.state === 'dirty' && - loadedDraft.draft.baseRevision !== record.revision + loadedDraft.draft.state === 'dirty' ) { - const rebased = await store.settleAcknowledgement( - projectId, - loadedDraft.draft.editorSessionId, - writerToken, - replaced.generation, - record.revision, - record.minimum_canvas_schema_version - ); - if (rebased.kind === 'rebased') { - loadedDraft.draft = rebased.draft; - volatileDrafts.set(projectId, rebased.draft); + const landed = splitProjectDocument(JSON.parse(replaced.documentJson) as ProjectDocumentV3); + const bases: ProjectDraftBases = {}; + const contentLanded = serverEntry.pushedContent + ? landed.contentJson === serverEntry.pushedContent + : replaced.documentJson === serverJson; + if (contentLanded && loadedDraft.draft.baseRevision !== record.revision) { + bases.baseRevision = record.revision; + bases.baseMinimumCanvasSchemaVersion = record.minimum_canvas_schema_version; + } + if ( + serverEntry.pushedWorkspace !== null && + landed.workspaceJson === serverEntry.pushedWorkspace && + (loadedDraft.draft.baseWorkspaceRevision ?? null) !== serverEntry.workspaceRevision + ) { + bases.baseWorkspaceRevision = serverEntry.workspaceRevision; + } + const rebased = + Object.keys(bases).length === 0 + ? null + : await store.rebaseDraft( + projectId, + loadedDraft.draft.editorSessionId, + writerToken, + replaced.generation, + bases + ); + if (rebased?.kind === 'rebased') { + loadedDraft.draft = withBases(loadedDraft.draft, bases); + volatileDrafts.set(projectId, loadedDraft.draft); } } + const divergence = + loadedDraft.draft.state === 'conflict' + ? loadedDraft.draft.conflict + : findDraftDivergence(loadedDraft.draft, serverLoad.project, record); if ( loadedDraft.draft.state === 'schema-refused' && loadedDraft.draft.refusal.kind === 'canvas' && @@ -2660,11 +3054,8 @@ export const createDurableSyncedWorkbenchPersistence = ( schemaRefusals.set(projectId, loadedDraft.draft.refusal); pendingProjectIds.add(projectId); hasPending = true; - } else if (loadedDraft.draft.state === 'conflict' || loadedDraft.draft.baseRevision !== record.revision) { - const conflict = - loadedDraft.draft.state === 'conflict' - ? loadedDraft.draft.conflict - : ({ kind: 'revision', serverRevision: record.revision } as const); + } else if (divergence) { + const conflict = divergence; await markConflict( projectId, loadedDraft.draft.generation, @@ -2674,7 +3065,7 @@ export const createDurableSyncedWorkbenchPersistence = ( ); } else { if (loadedDraft.draft.state === 'schema-refused') { - await store.resumeSchemaRefused( + await store.resumeDraft( projectId, loadedDraft.draft.editorSessionId, writerToken, @@ -3008,6 +3399,7 @@ export const createDurableSyncedWorkbenchPersistence = ( } const settlement = await store.retargetAcknowledgedCopy({ acknowledgedRevision: record.revision, + acknowledgedWorkspaceRevision: record.workspace?.revision ?? null, copyProjectId, editorSessionId, projectId, @@ -3027,6 +3419,7 @@ export const createDurableSyncedWorkbenchPersistence = ( latestVolatile && latestVolatile.generation > reservation.copyProjectGeneration ? toDirtyProjectDraft(latestVolatile, { baseRevision: record.revision, + baseWorkspaceRevision: record.workspace?.revision ?? null, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectGeneration: undefined, @@ -3042,18 +3435,17 @@ export const createDurableSyncedWorkbenchPersistence = ( if (volatileRetargeted) { volatileDrafts.set(copyProjectId, volatileRetargeted); } - const acknowledgedJson = JSON.stringify(record.data); + const loadedCopy = deserializeProjectRecord(record); syncEntries.delete(projectId); + sentHalves.delete(projectId); conflicts.delete(projectId); projectsRequiringDocumentUpgrade.delete(projectId); schemaRefusals.delete(projectId); pendingProjectIds.delete(projectId); retargetedProjects.delete(projectId); - syncEntries.set(copyProjectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: acknowledgedJson, - revision: record.revision, - }); + if (loadedCopy.status === 'loaded') { + syncEntries.set(copyProjectId, toSyncEntry(record, loadedCopy.project)); + } upsertProjectSummary( { id: copyProjectId, @@ -3063,7 +3455,7 @@ export const createDurableSyncedWorkbenchPersistence = ( }, owner ); - recordProjectCover(copyProjectId, selectCoverImageName(record.data), owner); + recordProjectCover(copyProjectId, selectCoverImageName(document), owner); const durableRetargeted = settlement.kind === 'retargeted' ? settlement.draft : null; const survivingDraft = durableRetargeted && volatileRetargeted @@ -3100,7 +3492,6 @@ export const createDurableSyncedWorkbenchPersistence = ( }); recomputeHasPending(); resolveProjectSyncConflict(projectId, { projectId: copyProjectId, revision: record.revision }, hasPending); - const loadedCopy = deserializeProjectRecord(record); const visible = survivingDraft ? loadDraftProject(survivingDraft) : loadedCopy.status === 'loaded' @@ -3143,13 +3534,18 @@ export const createDurableSyncedWorkbenchPersistence = ( projectResolutionFences.delete(projectId); return result; } + const conflict = conflicts.get(projectId); + // Without a workspace row (one never saved, or since removed) the merge takes the one a new project starts with. + if (conflict?.kind === 'revision' && conflict.half === 'workspace' && isSplitProjectRecord(record)) { + return useServerWorkspace(projectId, record, result.project); + } await deleteDraft(projectId); - const documentJson = serializeProjectDocumentV3Json(result.project).documentJson; - syncEntries.set(projectId, { - minimumCanvasSchemaVersion: record.minimum_canvas_schema_version, - pushedDoc: documentJson, - revision: record.revision, - }); + syncEntries.set(projectId, toSyncEntry(record, result.project)); + if (recordRequiresDocumentUpgrade(record, result.project)) { + projectsRequiringDocumentUpgrade.add(projectId); + } else { + projectsRequiringDocumentUpgrade.delete(projectId); + } conflicts.delete(projectId); schemaRefusals.delete(projectId); pendingProjectIds.delete(projectId); @@ -3185,6 +3581,7 @@ export const createDurableSyncedWorkbenchPersistence = ( return; } syncEntries.delete(projectId); + sentHalves.delete(projectId); conflicts.delete(projectId); schemaRefusals.delete(projectId); generations.delete(projectId); diff --git a/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts b/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts index 635b9d32b2f..1ab6e2c4bf3 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/indexedDbDraftStore.ts @@ -27,6 +27,7 @@ import { PROJECT_DRAFT_PROJECT_LIMIT, toConflictProjectDraft, toDirtyProjectDraft, + withBases, toProjectDraftBody, toProjectDraftMetadata, toSchemaRefusedProjectDraft, @@ -45,6 +46,7 @@ import { type ProjectDraftRetargetAcknowledgeResult, type ProjectDraftRetargetHandoff, type ProjectDraftRetargetListResult, + type ProjectDraftRebaseResult, type ProjectDraftSettlementResult, type ProjectDraftStageResult, type ProjectDraftStartWriterResult, @@ -262,7 +264,25 @@ export const createIndexedDbProjectDraftStore = ( transform: DraftTransform, kind: 'marked' | 'rebased' ): Promise => { - const outcome = await mutate( + const outcome = await settleMetadata(projectId, editorSessionId, writerToken, sentGeneration, transform); + if (outcome.kind !== 'read-marked') { + return outcome; + } + const read = await readDraft(projectId, editorSessionId); + if (read.kind === 'found') { + return read.draft.writerToken === writerToken ? { draft: read.draft, kind } : { kind: 'fenced' }; + } + return read.kind === 'corrupt' || read.kind === 'unavailable' ? read : { kind: 'missing' }; + }; + /** The settlement's metadata write alone, for a caller that needs no draft back: the body is not read. */ + const settleMetadata = ( + projectId: string, + editorSessionId: string, + writerToken: string, + sentGeneration: number, + transform: DraftTransform + ): Promise => + mutate( async () => { const transaction = observeTransaction(database.transaction(DRAFT_STORES, 'readwrite')); const metadataStore = transaction.objectStore(WORKBENCH_DRAFT_STORE); @@ -314,15 +334,6 @@ export const createIndexedDbProjectDraftStore = ( { kind: 'unavailable' }, { kind: 'quota' } ); - if (outcome.kind !== 'read-marked') { - return outcome; - } - const read = await readDraft(projectId, editorSessionId); - if (read.kind === 'found') { - return read.draft.writerToken === writerToken ? { draft: read.draft, kind } : { kind: 'fenced' }; - } - return read.kind === 'corrupt' || read.kind === 'unavailable' ? read : { kind: 'missing' }; - }; return { get availability() { @@ -961,7 +972,13 @@ export const createIndexedDbProjectDraftStore = ( { kind: 'quota' } ); }, - resumeSchemaRefused(projectId, editorSessionId, writerToken, generation) { + async rebaseDraft(projectId, editorSessionId, writerToken, generation, bases) { + const settled = await settleMetadata(projectId, editorSessionId, writerToken, generation, (draft) => + withBases(draft, bases) + ); + return settled.kind === 'read-marked' ? { kind: 'rebased' } : (settled as ProjectDraftRebaseResult); + }, + resumeDraft(projectId, editorSessionId, writerToken, generation) { return settle( projectId, editorSessionId, @@ -1055,6 +1072,7 @@ export const createIndexedDbProjectDraftStore = ( ? null : toDirtyProjectDraft(read.draft, { baseRevision: options.acknowledgedRevision, + baseWorkspaceRevision: options.acknowledgedWorkspaceRevision, copyDocumentByteSize: undefined, copyDocumentJson: undefined, copyProjectId: undefined, @@ -1110,7 +1128,8 @@ export const createIndexedDbProjectDraftStore = ( writerToken, sentGeneration, acknowledgedRevision, - acknowledgedMinimumCanvasSchemaVersion + acknowledgedMinimumCanvasSchemaVersion, + acknowledgedWorkspaceRevision ) { const outcome = await mutate( async () => { @@ -1165,6 +1184,9 @@ export const createIndexedDbProjectDraftStore = ( baseMinimumCanvasSchemaVersion: acknowledgedMinimumCanvasSchemaVersion ?? metadata.baseMinimumCanvasSchemaVersion, baseRevision: acknowledgedRevision, + ...(acknowledgedWorkspaceRevision === undefined + ? {} + : { baseWorkspaceRevision: acknowledgedWorkspaceRevision }), metadataRevision: nextMetadataRevision, }); await writerStore.put( diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts index 54400f41788..2ca3c383ff5 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.test.ts @@ -1,5 +1,5 @@ import type { AccountScope } from '@platform/state/accountLifecycle'; -import type { ProjectBoardItemDTO, ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectBoardItemDTO, ProjectWithWorkspaceDTO } from '@workbench/projects/api'; import type * as apiModule from '@workbench/projects/api'; import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; @@ -19,6 +19,7 @@ import type * as duplicateProjectModule from './duplicateProject'; const api = vi.hoisted(() => ({ createProjectSettled: vi.fn(), getProject: vi.fn(), + saveFirstProjectWorkspace: vi.fn(), isProjectNotFoundError: (error: unknown) => typeof error === 'object' && error !== null && 'status' in error && error.status === 404, })); @@ -65,7 +66,7 @@ const rasterImageLayer = (id: string, imageName: string) => ({ type: 'raster' as const, }); -const sourceRecord = (document: Record = {}): ProjectRecordDTO => { +const sourceRecord = (document: Record = {}): ProjectWithWorkspaceDTO => { const project = createDraftProject([]); return { @@ -83,9 +84,12 @@ const sourceRecord = (document: Record = {}): ProjectRecordDTO }, name: 'Source', minimum_canvas_schema_version: 2, + minimum_document_schema_version: 3, project_id: 'source', revision: 4, updated_at: '2026-06-10 10:00:00.000', + workspace_updated_at: null, + workspace: null, }; }; @@ -143,6 +147,10 @@ beforeEach(async () => { }) ); + api.saveFirstProjectWorkspace.mockImplementation((record: object, data: Record) => + Promise.resolve({ ...record, workspace: { data, revision: 1, updated_at: '2026-06-10 11:00:00.000' } }) + ); + duplicateProject = await import('./duplicateProject'); }); @@ -222,12 +230,21 @@ describe('duplicateProjectRecord', () => { /** Same-server duplicates share external references. */ it('reuses a document reference the board does not own, copying nothing', async () => { - const record = sourceRecord({ futureImageInput: { image_name: 'external.png' } }); + const project = createDraftProject([]); + const record = sourceRecord({ + canvas: { + ...project.canvas, + document: { + ...project.canvas.document, + stacks: stacksFrom([rasterImageLayer('l1', 'shared.png'), rasterImageLayer('l2', 'external.png')]), + }, + }, + }); await duplicateProject.duplicateProjectRecord({ boards: inboxOf([boardItem()]), owner, record }); expect(transport.copyImagesToBoard).toHaveBeenCalledWith(['shared.png'], 'staging-board', owner.signal); - expect((createdData().futureImageInput as { image_name: string }).image_name).toBe('external.png'); + expect(JSON.stringify(createdData().canvas)).toContain('"external.png"'); }); /** A failed copy must never fall back to source-owned media. */ @@ -247,24 +264,71 @@ describe('duplicateProjectRecord', () => { }); it('canonicalizes the copy under its own identity and drops installation state', async () => { + const project = createDraftProject([]); + const gallery = Object.values(project.widgetInstances).find((instance) => instance.typeId === 'gallery')!; const record = sourceRecord({ - widgetStates: { gallery: { values: { projectBoardId: 'source-board', selectedBoardId: 'source-board' } } }, + widgetInstances: { + ...project.widgetInstances, + [gallery.id]: { + ...gallery, + state: { + ...gallery.state, + values: { ...gallery.state.values, projectBoardId: 'source-board', selectedBoardId: 'source-board' }, + }, + }, + }, }); const result = await duplicateProject.duplicateProjectRecord({ boards: inboxOf([boardItem()]), owner, record }); - const gallery = (createdData().widgetStates as { gallery: { values: Record } }).gallery.values; - // The document that goes to the server carries neither the original's board nor its selection. - expect(gallery.projectBoardId).toBeUndefined(); - expect(gallery.selectedBoardId).toBeUndefined(); + // The content carries no gallery state at all, and is the copy's under its own identity. + expect(createdData().widgetInstances).toBeUndefined(); expect(createdData().id).toBe(result.record.project_id); expect(createdData().name).toBe('Source copy'); + // The duplicating account's workspace opens on the board the server says the copy claimed, never the original's. + const saved = api.saveFirstProjectWorkspace.mock.calls[0]![1] as { + widgetInstances: typeof project.widgetInstances; + }; + expect(saved.widgetInstances[gallery.id]!.state.values).toMatchObject({ + projectBoardId: 'staging-board', + selectedBoardId: 'staging-board', + }); + expect(result.record.workspace?.data).toEqual(saved); + }); + + it('keeps the copy when its workspace cannot be saved: it opens on a new project’s workspace', async () => { + api.saveFirstProjectWorkspace.mockRejectedValueOnce(new Error('Server error')); + + const result = await duplicateProject.duplicateProjectRecord({ + boards: inboxOf([]), + owner, + record: sourceRecord(), + }); + + expect(result.record).toMatchObject({ project_id: expect.any(String), workspace: null }); + expect(transport.placeBoardInProject).not.toHaveBeenCalled(); + }); + + it('copies a project whose workflow repeats an item id whole, as no load would accept it split', async () => { + const project = createDraftProject([]); + const node = { data: { label: '', notes: '' }, id: 'node-1', position: { x: 0, y: 0 }, type: 'notes' }; + const record = sourceRecord({ + documentSchemaVersion: 3, + workflows: { + ...project.workflows, + entries: project.workflows.entries.map((entry) => ({ + ...entry, + document: { ...entry.document, nodes: [node, node] }, + })), + }, + }); - // The returned record is patched with the board the server says it claimed, and points at it. - const claimed = (result.record.data.widgetStates as { gallery: { values: Record } }).gallery - .values; + const result = await duplicateProject.duplicateProjectRecord({ boards: inboxOf([]), owner, record }); - expect(claimed).toMatchObject({ projectBoardId: 'staging-board', selectedBoardId: 'staging-board' }); + expect(api.createProjectSettled.mock.calls[0]![0]).toMatchObject({ minimum_document_schema_version: 3 }); + expect(createdData()).toMatchObject({ documentSchemaVersion: 3, widgetInstances: expect.any(Object) }); + expect(api.saveFirstProjectWorkspace).not.toHaveBeenCalled(); + expect(result.record.workspace).toBeNull(); }); it('keeps each workflow linked to its library template, since the copy lives on the same server', async () => { @@ -283,7 +347,7 @@ describe('duplicateProjectRecord', () => { const workflows = createdData().workflows as { entries: { source?: unknown }[] }; expect(workflows.entries[0]?.source).toEqual(source); - expect(createdData().documentSchemaVersion).toBe(3); + expect(createdData().documentSchemaVersion).toBe(4); }); it('stages, copies and places every other board of the source, empty ones included', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts index 997262bf297..5fee3209335 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/invk/duplicateProject.ts @@ -1,7 +1,8 @@ -import type { ProjectBoardSnapshotBoardDTO, ProjectRecordDTO } from '@workbench/projects/api'; +import type { ProjectBoardSnapshotBoardDTO, ProjectWithWorkspaceDTO } from '@workbench/projects/api'; +import type { ProjectDocumentV3 } from '@workbench/projects/projectDocument'; import { type AccountScope, assertAccountScopeCurrent, isAccountScopeCurrent } from '@platform/state/accountLifecycle'; -import { createProjectSettled } from '@workbench/projects/api'; +import { createProjectSettled, saveFirstProjectWorkspace } from '@workbench/projects/api'; import { createProjectId } from '@workbench/projects/ids'; import { collectLiveAssetRefs, @@ -32,7 +33,7 @@ export interface DuplicateProjectInput { boards: readonly ProjectBoardSnapshotBoardDTO[]; owner: AccountScope; /** The acknowledged source record — for an open project, flushed first. */ - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; /** Stable identity reserved before the operation, used by retry-safe conflict copies. */ identity?: { id: string; name: string }; } @@ -46,7 +47,7 @@ export interface DuplicateProjectDeps { export interface DuplicateProjectResult extends ProjectTransferIssues { /** The cover to record for the copy, or `null` when the source had none this server can serve. */ coverImageName: string | null; - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; } /** @@ -104,12 +105,18 @@ export const duplicateProjectRecord = async ( assertAccountScopeCurrent(owner); - // Canonicalize with a fresh project identity and reset source board/selection state. - const { deserializeProjectDocument } = await import('@workbench/projects/syncedPersistence'); + // Canonicalize with a fresh project identity and reset source board/selection state. The copy carries the + // duplicating account's workspace, never another member's: the source record's is the caller's own. + const [{ deserializeProjectDocument, joinProjectRecord }, { splitProjectDocument, toProjectCreation }] = + await Promise.all([import('@workbench/projects/projectHydration'), import('@workbench/projects/projectHalves')]); assertAccountScopeCurrent(owner); - const loaded = deserializeProjectDocument({ ...stripInstallationState(input.record.data), id, name }); + const source = joinProjectRecord(input.record); + if (source.status === 'malformed') { + throw new InvkFormatError('damaged', source.reason); + } + const loaded = deserializeProjectDocument({ ...stripInstallationState(source.document), id, name }); if (loaded.status === 'refused') { throw new InvkFormatError(toInvkFormatReason(loaded.refused), 'The project document was refused.'); @@ -179,10 +186,13 @@ export const duplicateProjectRecord = async ( assertAccountScopeCurrent(owner); didAttemptProjectCreate = true; + const copied = remapAssetRefs(canonicalDocument, restored.mappings) as unknown as ProjectDocumentV3; + const creation = toProjectCreation(copied); const record = await createProjectSettled( { - data: remapAssetRefs(canonicalDocument, restored.mappings), + data: creation.data as unknown as Record, minimum_canvas_schema_version: input.record.minimum_canvas_schema_version, + minimum_document_schema_version: creation.documentSchemaVersion, name, project_id: id, ...(inboxStagingBoardId === null ? {} : { board_id: inboxStagingBoardId }), @@ -195,16 +205,33 @@ export const duplicateProjectRecord = async ( // The copy exists; its other boards can now belong to it. Reported, never fatal, from here on. const boardIssues = await placeMemberBoards(stagedBoards, record.project_id, { signal: owner.signal }); assertAccountScopeCurrent(owner); + // The duplicating account's workspace, opening on the copy's own inbox, which only the created record names. + // Never fatal either: without it the copy opens on the workspace a new project starts with. A copy created whole + // carries its workspace inside. + const saveOwnWorkspace = () => + saveFirstProjectWorkspace( + record, + splitProjectDocument( + applyAuthoritativeProjectBoard(copied as unknown as Record, record.board_id, { + selectBoard: true, + }) as unknown as ProjectDocumentV3 + ).workspace as unknown as Record, + owner.signal + ).catch((error: unknown) => { + if (isRequestCancellation(error) || !isAccountScopeCurrent(owner)) { + throw error; + } + return { ...record, workspace: null }; + }); + const copy = creation.workspace ? await saveOwnWorkspace() : { ...record, workspace: null }; + assertAccountScopeCurrent(owner); return { boardIssues, boardItemIssues: restored.boardItemIssues, coverImageName: restored.coverImageName, documentReferenceIssues: restored.documentReferenceIssues, - record: { - ...record, - data: applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: true }), - }, + record: copy, }; } catch (error) { const rollback = () => rollbackRestoredMedia(ledger, { signal: owner.signal }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/library.test.ts b/invokeai/frontend/webv2/src/workbench/projects/library.test.ts index 1291c60cea4..1789acc7c9a 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/library.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/library.test.ts @@ -152,6 +152,45 @@ describe('refreshProjectLibrary', () => { expect(api.getProject).not.toHaveBeenCalled(); }); + it('dates each project by when the caller last worked on it: the later of its content and their workspace', async () => { + api.listProjects.mockResolvedValue([ + // Content saved last week, but the caller's own workspace saved since: worked on recently. + { + ...summaryDto('workspace', 'Workspace', '2026-06-03 10:00:00.000'), + workspace_updated_at: '2026-06-11 09:00:00.000', + }, + { + ...summaryDto('content', 'Content', '2026-06-10 10:00:00.000'), + workspace_updated_at: '2026-06-01 10:00:00.000', + }, + { ...summaryDto('none', 'None', '2026-06-09 10:00:00.000'), workspace_updated_at: null }, + ]); + + await library.refreshProjectLibrary(); + + const { summaries } = library.getProjectLibrary(); + expect(summaries.map((summary) => [summary.id, summary.updatedAt])).toEqual([ + ['workspace', '2026-06-11T09:00:00.000Z'], + ['content', '2026-06-10T10:00:00.000Z'], + ['none', '2026-06-09T10:00:00.000Z'], + ]); + }); + + it('identifies summaries that require a newer document reader without fetching the document', async () => { + api.listProjects.mockResolvedValue([ + { ...summaryDto('split', 'Split', '2026-06-10 10:00:00.000'), minimum_document_schema_version: 4 }, + { ...summaryDto('future', 'Future', '2026-06-10 10:00:00.000'), minimum_document_schema_version: 5 }, + ]); + + await library.refreshProjectLibrary(); + + const compatible = Object.fromEntries( + library.getProjectLibrary().summaries.map((summary) => [summary.id, library.isProjectSummaryCompatible(summary)]) + ); + expect(compatible).toEqual({ future: false, split: true }); + expect(api.getProject).not.toHaveBeenCalled(); + }); + it('keeps the previous summaries and reports the failure on error', async () => { api.listProjects.mockResolvedValue([summaryDto('kept', 'Kept', '2026-06-10 10:00:00.000')]); await library.refreshProjectLibrary(); diff --git a/invokeai/frontend/webv2/src/workbench/projects/library.ts b/invokeai/frontend/webv2/src/workbench/projects/library.ts index 30a43f6dde1..7fa016a8d54 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/library.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/library.ts @@ -20,6 +20,7 @@ import { listProjects, updateProject as apiUpdateProject, type ProjectRecordDTO, + type ProjectWithWorkspaceDTO, type ProjectSummaryDTO, } from './api'; import { @@ -30,6 +31,7 @@ import { recordProjectCover, subscribeProjectCovers, } from './covers'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { refreshOpenProjects } from './openProjects'; import { assertProjectFlushed } from './projectFlush'; import { pruneSessionProject } from './session'; @@ -42,7 +44,10 @@ export interface ProjectSummary { name: string; revision: number; minimumCanvasSchemaVersion: number; + /** Absent before the server reported it: a project saved whole. */ + minimumDocumentSchemaVersion?: number; createdAt: string; + /** The later of the content's save and the caller's own workspace save: when they last worked on it. */ updatedAt: string; /** The cover index is independent of document-free listings; absent entries yield no cover. */ coverUrl?: string; @@ -77,9 +82,13 @@ const toSummary = (dto: ProjectSummaryDTO): ProjectSummary => createdAt: normalizeServerTimestamp(dto.created_at), id: dto.project_id, minimumCanvasSchemaVersion: dto.minimum_canvas_schema_version, + minimumDocumentSchemaVersion: dto.minimum_document_schema_version, name: dto.name, revision: dto.revision, - updatedAt: normalizeServerTimestamp(dto.updated_at), + updatedAt: [dto.updated_at, dto.workspace_updated_at] + .filter((timestamp): timestamp is string => typeof timestamp === 'string') + .map(normalizeServerTimestamp) + .reduce((latest, timestamp) => (timestamp > latest ? timestamp : latest)), }); /** Recompute summaries when the independent cover index changes. */ @@ -176,7 +185,8 @@ export const upsertProjectSummary = ( }; export const isProjectSummaryCompatible = (summary: ProjectSummary): boolean => - isCanvasSchemaVersionSupported(summary.minimumCanvasSchemaVersion); + isCanvasSchemaVersionSupported(summary.minimumCanvasSchemaVersion) && + (summary.minimumDocumentSchemaVersion ?? 0) <= PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; /** Open projects mutate through their sync engine; closed projects use the HTTP API directly. */ @@ -271,7 +281,10 @@ export const renameLibraryProject = async (projectId: string, name: string): Pro }; /** Export/copy requires the paint barrier and an acknowledged flush before reading server bytes. */ -export const readAcknowledgedProject = async (projectId: string, owner: AccountScope): Promise => { +export const readAcknowledgedProject = async ( + projectId: string, + owner: AccountScope +): Promise => { const openProject = getOpenProject(projectId); if (openProject) { diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts b/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts index 61494fa6948..7f1a192986e 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectFile.ts @@ -12,7 +12,7 @@ import { getProjectCanvasSchemaRequirement, MAX_SUPPORTED_CANVAS_SCHEMA_VERSION import type { ProjectTransferIssues } from './invk/transfer'; -import { createProjectSettled, getProjectBoardSnapshot, type ProjectRecordDTO } from './api'; +import { createProjectSettled, getProjectBoardSnapshot, type ProjectWithWorkspaceDTO } from './api'; import { recordProjectCover } from './covers'; import { createProjectId } from './ids'; import { INVK_EXTENSION, InvkFormatError, toInvkFormatReason } from './invk/format'; @@ -77,7 +77,7 @@ export interface ProjectExportOutcome extends ProjectTransferIssues { } export interface ProjectImportOutcome extends ProjectTransferIssues { - record: ProjectRecordDTO; + record: ProjectWithWorkspaceDTO; } const readProjectDocument = async (file: File) => { @@ -160,11 +160,11 @@ export const exportLibraryProject = async ( assertAccountScopeCurrent(owner); - const [{ deserializeProjectDocument }, { serializeProjectDocumentV3 }] = await Promise.all([ + const [{ deserializeProjectRecord }, { serializeProjectDocumentV3 }] = await Promise.all([ import('./projectHydration'), import('./projectDocument'), ]); - const loaded = deserializeProjectDocument(record.data); + const loaded = deserializeProjectRecord(record); const document = loaded.status === 'loaded' ? serializeProjectDocumentV3(loaded.project) : record.data; return exportProjectDocument(record.name, record.project_id, document, record.minimum_canvas_schema_version, { @@ -362,10 +362,12 @@ export const importProjectFile = async ( boardIssues, boardItemIssues: restored?.boardItemIssues ?? [], documentReferenceIssues: restored?.documentReferenceIssues ?? [], - // Bind the initial selection to the authoritative returned board ID, not the requested ID. + // Bind the initial selection to the authoritative returned board ID, not the requested ID. Created whole, the + // project has no workspace until its first save splits it. record: { ...record, data: applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: true }), + workspace: null, }, }; } catch (error) { diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts new file mode 100644 index 00000000000..6f13d6f7e1c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.test.ts @@ -0,0 +1,183 @@ +import type { Project } from '@workbench/projectContracts'; + +import { addProjectWorkflow, createBlankWorkflowDocument, recordProjectWorkflowRun } from '@workbench/projectWorkflows'; +import { createInitialWorkbenchState } from '@workbench/workbenchState.testing'; +import { describe, expect, it } from 'vitest'; + +import { serializeProjectDocumentV3, serializeProjectDocumentV3Json } from './projectDocument'; +import { joinProjectDocument, PROJECT_DOCUMENT_HALVES, splitProjectDocument } from './projectHalves'; +import { deserializeProjectDocument } from './syncedPersistence'; + +const RUN = { completedAt: '2026-10-10T00:00:02.000Z', imageName: 'run.png', submittedAt: '2026-10-10T00:00:01.000Z' }; + +/** A project whose workspace differs from a fresh one: a second, active workflow with a run, and a staged image. */ +const workedOnProject = (): Project => { + const project = createInitialWorkbenchState().projects[0]!; + const second = createBlankWorkflowDocument(); + const withSecond = recordProjectWorkflowRun(addProjectWorkflow(project, second), second.id, RUN); + return { + ...withSecond, + canvas: { + ...withSecond.canvas, + stagingArea: { ...withSecond.canvas.stagingArea, isVisible: false, selectedImageIndex: 2 }, + }, + }; +}; + +const defaultsOf = (project: Project) => () => { + const { documentSchemaVersion: _schema, ...workspace } = splitProjectDocument( + serializeProjectDocumentV3(project) + ).workspace; + return workspace; +}; + +const load = (document: Record): Project => { + const loaded = deserializeProjectDocument(document); + if (loaded.status !== 'loaded') { + throw new Error(`Expected the document to load, got ${loaded.status}.`); + } + return loaded.project; +}; + +const join = (halves: { content: object; workspace: object | null }, defaults = defaultsOf(workedOnProject())) => + joinProjectDocument( + halves.content as Record, + halves.workspace as Record | null, + defaults + ); + +describe('splitting a project into content and workspace', () => { + it('rejoins to the same whole document', () => { + const project = workedOnProject(); + const halves = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ content: JSON.parse(halves.contentJson), workspace: JSON.parse(halves.workspaceJson) }); + + expect(joined.status).toBe('migrated'); + const rejoined = load((joined as { document: Record }).document); + // Against the project as loading leaves it: the canonical form sync compares. + expect(serializeProjectDocumentV3Json(rejoined).documentJson).toBe( + serializeProjectDocumentV3Json(load(serializeProjectDocumentV3(project))).documentJson + ); + }); + + it('carries every field of the document in one half or the other', () => { + const project = workedOnProject(); + const geometry = { height: 300, width: 400, x: 10, y: 20 }; + const optional = { + ...project, + floatingWidgets: { gallery: { ...geometry, zIndex: 1 } }, + lastFloatingGeometry: { gallery: geometry }, + presetWorkingLayouts: [{ presetId: project.layout.presetId, layout: project.layout }], + } as unknown as Project; + const document = serializeProjectDocumentV3(optional); + const { content, workspace } = splitProjectDocument(document); + + for (const [field, half] of Object.entries(PROJECT_DOCUMENT_HALVES)) { + const holders = [field in content && 'content', field in workspace && 'workspace'].filter(Boolean); + expect(holders, field).toEqual(half === 'both' ? ['content', 'workspace'] : [half]); + } + expect(Object.keys(document).sort()).toEqual(Object.keys(PROJECT_DOCUMENT_HALVES).sort()); + }); + + it('keeps how one member works out of the shared content', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + + expect(Object.keys(content).sort()).toEqual(['canvas', 'documentSchemaVersion', 'id', 'name', 'workflows']); + expect('stagingArea' in content.canvas).toBe(false); + expect(content.canvas.document.selectedLayerId).toBeNull(); + expect(content.workflows.entries.some((entry) => 'lastRun' in entry)).toBe(false); + expect(Object.values(workspace.workflows.lastRuns)).toEqual([RUN]); + expect(workspace.canvas.stagingArea.selectedImageIndex).toBe(2); + }); + + it('changes only the half an edit belongs to', () => { + const project = workedOnProject(); + const before = splitProjectDocument(serializeProjectDocumentV3(project)); + + const relaid = splitProjectDocument( + serializeProjectDocumentV3({ ...project, layout: { ...project.layout, centerViewId: 'gallery' } }) + ); + const renamed = splitProjectDocument(serializeProjectDocumentV3({ ...project, name: 'Renamed' })); + + expect(relaid.contentJson).toBe(before.contentJson); + expect(relaid.workspaceJson).not.toBe(before.workspaceJson); + expect(renamed.workspaceJson).toBe(before.workspaceJson); + expect(renamed.contentJson).not.toBe(before.contentJson); + }); +}); + +describe('joining a project for a member', () => { + it('starts a member with no workspace on the defaults, on the first workflow', () => { + const project = workedOnProject(); + const { content } = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ content, workspace: null }); + + const loaded = load((joined as { document: Record }).document); + expect(loaded.workflows.activeWorkflowId).toBe(project.workflows.entries[0]!.document.id); + expect(loaded.workflows.entries.some((entry) => entry.lastRun)).toBe(false); + expect(loaded.name).toBe(project.name); + }); + + it('falls back to the first workflow when the open one was deleted meanwhile', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + + const joined = join({ + content, + workspace: { ...workspace, workflows: { activeWorkflowId: 'gone', lastRuns: {} } }, + }); + + expect(load((joined as { document: Record }).document).workflows.activeWorkflowId).toBe( + project.workflows.entries[0]!.document.id + ); + }); + + it('refuses content that repeats an item id within a workflow, which a lock could not tell apart', () => { + const project = workedOnProject(); + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(project)); + const [first, ...rest] = content.workflows.entries; + const node = { id: 'node-1', type: 'notes' }; + const repeated = { ...first!, document: { ...first!.document, nodes: [node, node] } }; + const elsewhere = rest.map((entry) => ({ ...entry, document: { ...entry.document, nodes: [node] } })); + + expect(join({ content: { ...content, workflows: { entries: [repeated, ...rest] } }, workspace }).status).toBe( + 'malformed' + ); + // Workflow duplication keeps node ids, so two workflows may share one. + expect( + join({ + content: { + ...content, + workflows: { entries: [{ ...first!, document: { ...first!.document, nodes: [node] } }, ...elsewhere] }, + }, + workspace, + }).status + ).toBe('migrated'); + }); + + it('refuses repeated edge and form element ids too', () => { + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(workedOnProject())); + const [first, ...rest] = content.workflows.entries; + const edge = { id: 'edge-1', source: 'a', sourceHandle: 'value', target: 'b', targetHandle: 'value' }; + const element = { data: { children: [], layout: 'column' }, id: 'container-1', type: 'container' }; + const repeating = (document: object) => ({ + ...content, + workflows: { entries: [{ ...first!, document: { ...first!.document, ...document } }, ...rest] }, + }); + + expect(join({ content: repeating({ edges: [edge, edge] }), workspace }).status).toBe('malformed'); + expect( + join({ content: repeating({ form: { elements: { a: element, b: element }, rootElementId: 'a' } }), workspace }) + .status + ).toBe('malformed'); + }); + + it('refuses a workspace of another document schema', () => { + const { content, workspace } = splitProjectDocument(serializeProjectDocumentV3(workedOnProject())); + + expect(join({ content, workspace: { ...workspace, documentSchemaVersion: 5 } }).status).toBe('malformed'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts new file mode 100644 index 00000000000..73a5b551b13 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHalves.ts @@ -0,0 +1,228 @@ +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; +import { + PROJECT_DOCUMENT_SCHEMA_VERSION, + type ProjectDocumentMigration, + type ProjectDocumentV3, +} from './projectDocument'; + +/* + * The project document as the server stores it from schema 4: the project **content** (what the project is, shared + * with every member) and each member's **workspace** (how they work on it). The browser keeps one whole document per + * project; the split happens only here, at the server boundary. + */ + +type CanvasState = ProjectDocumentV3['canvas']; +type ProjectWorkflowEntry = ProjectDocumentV3['workflows']['entries'][number]; +type ProjectWorkflowRunPreview = NonNullable; + +export interface ProjectContentV4 { + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + id: string; + name: string; + /** Without the staging area, and with no layer selected: both are the workspace's. */ + canvas: Omit; + /** Each entry without its last run, which belongs to whoever ran it. */ + workflows: { entries: Omit[] }; +} + +export interface ProjectWorkspaceV4 extends Pick< + ProjectDocumentV3, + | 'floatingWidgets' + | 'invocation' + | 'lastFloatingGeometry' + | 'layout' + | 'presetWorkingLayouts' + | 'promptHistory' + | 'settings' + | 'widgetGraphs' + | 'widgetInstances' + | 'widgetRegions' +> { + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + canvas: { selectedLayerId: string | null; stagingArea: CanvasState['stagingArea'] }; + workflows: { activeWorkflowId: string; lastRuns: Record }; +} + +/** + * Which half holds each field of the whole document (`both` for one split inside). Typed against the document, so a + * field added to it fails to compile until it is placed here, and in `splitProjectDocument`. + */ +export const PROJECT_DOCUMENT_HALVES = { + canvas: 'both', + documentSchemaVersion: 'both', + floatingWidgets: 'workspace', + id: 'content', + invocation: 'workspace', + lastFloatingGeometry: 'workspace', + layout: 'workspace', + name: 'content', + presetWorkingLayouts: 'workspace', + promptHistory: 'workspace', + settings: 'workspace', + widgetGraphs: 'workspace', + widgetInstances: 'workspace', + widgetRegions: 'workspace', + workflows: 'both', +} as const satisfies Record; + +export interface ProjectHalves { + content: ProjectContentV4; + contentJson: string; + workspace: ProjectWorkspaceV4; + workspaceJson: string; +} + +/** Splits a whole document into the two halves the server saves apart. */ +export const splitProjectDocument = (document: ProjectDocumentV3): ProjectHalves => { + const { stagingArea, ...canvas } = document.canvas; + const lastRuns: Record = {}; + const entries = document.workflows.entries.map(({ lastRun, ...entry }) => { + if (lastRun) { + lastRuns[entry.document.id] = lastRun; + } + return entry; + }); + const content: ProjectContentV4 = { + canvas: { ...canvas, document: { ...canvas.document, selectedLayerId: null } }, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + id: document.id, + name: document.name, + workflows: { entries }, + }; + const workspace: ProjectWorkspaceV4 = { + canvas: { selectedLayerId: document.canvas.document.selectedLayerId, stagingArea }, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + ...(document.floatingWidgets ? { floatingWidgets: document.floatingWidgets } : {}), + invocation: document.invocation, + ...(document.lastFloatingGeometry ? { lastFloatingGeometry: document.lastFloatingGeometry } : {}), + layout: document.layout, + ...(document.presetWorkingLayouts ? { presetWorkingLayouts: document.presetWorkingLayouts } : {}), + promptHistory: document.promptHistory, + settings: document.settings, + widgetGraphs: document.widgetGraphs, + widgetInstances: document.widgetInstances, + widgetRegions: document.widgetRegions, + workflows: { activeWorkflowId: document.workflows.activeWorkflowId, lastRuns }, + }; + + return { content, contentJson: JSON.stringify(content), workspace, workspaceJson: JSON.stringify(workspace) }; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const repeats = (ids: unknown[]): boolean => new Set(ids).size !== ids.length; + +/** + * The item ids a lock or presence marker names must each name one item (SF-12): within a workflow, its nodes, its + * edges and its form elements. Layers are already unique across a canvas document's stacks, checked as it loads. + * Refused rather than repaired: a repair would have to guess which of two items an edit meant. + */ +export const findRepeatedWorkflowItem = (entries: unknown[]): string | null => { + for (const entry of entries) { + const document = isRecord(entry) && isRecord(entry.document) ? entry.document : null; + if (!document) { + continue; + } + const idsOf = (items: unknown): unknown[] => + Array.isArray(items) ? items.map((item) => (isRecord(item) ? item.id : undefined)) : []; + const form = isRecord(document.form) && isRecord(document.form.elements) ? document.form.elements : {}; + if (repeats(idsOf(document.nodes)) || repeats(idsOf(document.edges)) || repeats(idsOf(Object.values(form)))) { + return typeof document.id === 'string' ? document.id : ''; + } + } + return null; +}; + +/** + * Joins the two halves into the whole document the browser works on, which the ordinary migration and load path then + * reads. A project with no workspace for the caller yet (a 7.1 member, or one never saved) gets `defaults`, the + * workspace a new project starts with. + */ +export const joinProjectDocument = ( + content: Record, + workspace: Record | null, + defaults: () => Omit +): ProjectDocumentMigration => { + if (workspace !== null && workspace.documentSchemaVersion !== PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION) { + return { reason: 'The workspace has an unsupported document schema.', status: 'malformed' }; + } + const canvas = isRecord(content.canvas) ? content.canvas : null; + const workflows = isRecord(content.workflows) && Array.isArray(content.workflows.entries) ? content.workflows : null; + if (!canvas || !isRecord(canvas.document) || !workflows) { + return { reason: 'The project content is missing its canvas or workflows.', status: 'malformed' }; + } + const entries = workflows.entries as unknown[]; + const repeated = findRepeatedWorkflowItem(entries); + if (repeated !== null) { + return { reason: `Workflow ${repeated} repeats an item id.`, status: 'malformed' }; + } + + const fallback = workspace ? null : defaults(); + const own = (workspace ?? fallback) as Record; + const ownCanvas = isRecord(own.canvas) ? own.canvas : {}; + const ownWorkflows = isRecord(own.workflows) ? own.workflows : {}; + const lastRuns = isRecord(ownWorkflows.lastRuns) ? ownWorkflows.lastRuns : {}; + const entryIds = entries.map((entry) => (isRecord(entry) && isRecord(entry.document) ? entry.document.id : null)); + // Another member may have deleted the workflow this workspace had open. + const activeWorkflowId = entryIds.includes(ownWorkflows.activeWorkflowId as string) + ? ownWorkflows.activeWorkflowId + : entryIds[0]; + const { + canvas: _ownCanvas, + documentSchemaVersion: _workspaceSchema, + workflows: _ownWorkflows, + ...workspaceFields + } = own; + const { documentSchemaVersion: _contentSchema, ...contentFields } = content; + + return { + document: { + ...workspaceFields, + ...contentFields, + canvas: { + ...canvas, + document: { + ...canvas.document, + selectedLayerId: typeof ownCanvas.selectedLayerId === 'string' ? ownCanvas.selectedLayerId : null, + }, + ...(ownCanvas.stagingArea === undefined ? {} : { stagingArea: ownCanvas.stagingArea }), + }, + // The whole-document schema the browser works in; the migration boundary reads it as current. + documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, + workflows: { + activeWorkflowId, + entries: entries.map((entry, index) => { + const run = lastRuns[entryIds[index] as string]; + return isRecord(entry) && isRecord(run) ? { ...entry, lastRun: run } : entry; + }), + }, + }, + status: 'migrated', + }; +}; + +/** + * How a document is first saved: its content, under the split floor, with the workspace to save beside it. A workflow + * that repeats an item id (only a build before the split could write one) would leave content no load accepts, so that + * project is created whole, as the server would already hold it, and keeps saving whole. + */ +export const toProjectCreation = ( + document: ProjectDocumentV3 +): + | { + data: ProjectContentV4; + documentSchemaVersion: typeof PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + workspace: ProjectWorkspaceV4; + } + | { data: ProjectDocumentV3; documentSchemaVersion: typeof PROJECT_DOCUMENT_SCHEMA_VERSION; workspace: null } => { + if (findRepeatedWorkflowItem(document.workflows.entries) !== null) { + return { data: document, documentSchemaVersion: PROJECT_DOCUMENT_SCHEMA_VERSION, workspace: null }; + } + const halves = splitProjectDocument(document); + return { + data: halves.content, + documentSchemaVersion: PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION, + workspace: halves.workspace, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts b/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts index f8314cdbd1e..6ee2f4ddb32 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectHydration.ts @@ -1,24 +1,26 @@ import type { Project, ProjectLoadResult } from '@workbench/projectContracts'; -import { loadWorkbenchProject, withAuthoritativeProjectBoard } from '@workbench/workbenchState'; +import { createDraftProject, loadWorkbenchProject, withAuthoritativeProjectBoard } from '@workbench/workbenchState'; -import type { ProjectRecordDTO } from './api'; +import type { ProjectWithWorkspaceDTO } from './api'; +import { PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION } from './documentSchemaVersion'; import { applyAuthoritativeProjectBoard, migrateProjectDocument, PROJECT_DOCUMENT_SCHEMA_VERSION, + serializeProjectDocumentV3, + type ProjectDocumentMigration, } from './projectDocument'; +import { joinProjectDocument, splitProjectDocument } from './projectHalves'; const toFutureProjectDocumentRefusal = ( data: Record, projectId = typeof data.id === 'string' ? data.id : '', - projectName = typeof data.name === 'string' ? data.name : '' + projectName = typeof data.name === 'string' ? data.name : '', + newestReadable = PROJECT_DOCUMENT_SCHEMA_VERSION ): ProjectLoadResult | null => { - if ( - !Number.isSafeInteger(data.documentSchemaVersion) || - (data.documentSchemaVersion as number) <= PROJECT_DOCUMENT_SCHEMA_VERSION - ) { + if (!Number.isSafeInteger(data.documentSchemaVersion) || (data.documentSchemaVersion as number) <= newestReadable) { return null; } @@ -68,13 +70,53 @@ export const deserializeProjectDocument = (data: Record): Proje return result.status === 'refused' ? { refused: { ...result.refused, raw: data }, status: 'refused' } : result; }; -export const deserializeProjectRecord = (record: ProjectRecordDTO): ProjectLoadResult => { - const futureDocument = toFutureProjectDocumentRefusal(record.data, record.project_id, record.name); +/** The workspace a project starts with, for a member who has not saved one in it. */ +const createDefaultProjectWorkspace = () => { + const { documentSchemaVersion: _schema, ...workspace } = splitProjectDocument( + serializeProjectDocumentV3(createDraftProject([])) + ).workspace; + return workspace; +}; + +/** Whether a record holds project content (document schema 4 on) rather than a document saved whole. */ +export const isSplitProjectRecord = (record: Pick): boolean => + record.data.documentSchemaVersion === PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION; + +/** + * The whole document a read holds: itself when saved whole, else its content joined with the caller's workspace, or + * with the defaults a new project starts with before they have saved one. + */ +export const joinProjectRecord = (record: ProjectWithWorkspaceDTO): ProjectDocumentMigration => + isSplitProjectRecord(record) + ? joinProjectDocument(record.data, record.workspace?.data ?? null, createDefaultProjectWorkspace) + : { document: record.data, status: 'migrated' }; + +export const deserializeProjectRecord = (record: ProjectWithWorkspaceDTO): ProjectLoadResult => { + const futureDocument = toFutureProjectDocumentRefusal( + record.data, + record.project_id, + record.name, + PROJECT_SPLIT_DOCUMENT_SCHEMA_VERSION + ); if (futureDocument) { return futureDocument; } + const joined = joinProjectRecord(record); + if (joined.status === 'malformed') { + return { + refused: { + projectId: record.project_id, + projectName: record.name, + raw: record.data, + refusal: { raw: record.data, reason: joined.reason, scope: 'project-document', status: 'malformed' }, + source: 'project-document', + }, + status: 'refused', + }; + } + const whole = joined.document; const result = deserializeProjectDocument( - applyAuthoritativeProjectBoard(record.data, record.board_id, { selectBoard: false }) + applyAuthoritativeProjectBoard(whole, record.board_id, { selectBoard: false }) ); if (result.status === 'loaded') { diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts index 8d3eb50608f..6615be0936c 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts @@ -1,6 +1,7 @@ import type { Project } from '@workbench/projectContracts'; import { addProjectWorkflow, getActiveProjectGraph, recordProjectWorkflowRun } from '@workbench/projectWorkflows'; +import { createDraftProject } from '@workbench/workbenchState'; import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState.testing'; import { describe, expect, it } from 'vitest'; @@ -9,6 +10,7 @@ import { serializeProjectDocumentV3, serializeProjectDocumentV3Json, } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; import { deserializeProjectDocument, deserializeProjectRecord, serializeProjectDocument } from './syncedPersistence'; const getProject = (overrides: Partial = {}): Project => { @@ -169,7 +171,7 @@ describe('project document serialization', () => { it('uses authoritative server identity and untouched bytes for a future document refusal', () => { const raw = { - documentSchemaVersion: 4, + documentSchemaVersion: 5, id: 'untrusted-id', name: 'Untrusted name', widgetInstances: { @@ -183,23 +185,55 @@ describe('project document serialization', () => { created_at: '2026-09-03T00:00:00.000Z', data: raw, minimum_canvas_schema_version: 4, + minimum_document_schema_version: 3, name: 'Authoritative name', project_id: 'authoritative-id', revision: 4, updated_at: '2026-09-03T00:00:00.000Z', + workspace_updated_at: null, + workspace: null, }) ).toEqual({ refused: { projectId: 'authoritative-id', projectName: 'Authoritative name', raw, - refusal: { raw, scope: 'project-document', status: 'unsupported-version', version: 4 }, + refusal: { raw, scope: 'project-document', status: 'unsupported-version', version: 5 }, source: 'project-document', }, status: 'refused', }); }); + it('opens a split project for a member with no workspace on the workspace a new project starts with', () => { + const fresh = createDraftProject([]); + const shared = { ...getProject(), name: 'Shared' }; + const { content } = splitProjectDocument(serializeProjectDocumentV3(shared)); + + const loaded = deserializeProjectRecord({ + board_id: 'board-1', + created_at: '2026-09-03T00:00:00.000Z', + data: content as unknown as Record, + minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, + name: 'Shared', + project_id: shared.id, + revision: 1, + updated_at: '2026-09-03T00:00:00.000Z', + workspace: null, + workspace_updated_at: null, + }); + + if (loaded.status !== 'loaded') { + throw new Error(`Expected the project to load, got ${loaded.status}.`); + } + expect(loaded.project.name).toBe('Shared'); + expect(loaded.project.layout).toEqual(fresh.layout); + expect(loaded.project.settings).toEqual(fresh.settings); + expect(Object.keys(loaded.project.widgetInstances).sort()).toEqual(Object.keys(fresh.widgetInstances).sort()); + expect(loaded.project.workflows.activeWorkflowId).toBe(shared.workflows.entries[0]!.document.id); + }); + it('migrates a schema-2 document into the first, active workflow with an unknown-revision source', () => { const project = getProject(); const legacyGraph = { ...getActiveProjectGraph(project), id: 'legacy-graph', libraryWorkflowId: 'lib-1' }; diff --git a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts index 32dc7a580bc..8354b52cec5 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts @@ -16,7 +16,13 @@ export interface ProjectSyncInfo { /** Present when the server requires a newer canvas schema than this client supports. */ schemaRefusal?: ProjectSchemaRefusal; /** Divergent local work awaits an explicit user decision. */ - conflict?: { detectedAt: string; kind: 'deleted' | 'revision'; serverRevision?: number }; + conflict?: { + detectedAt: string; + /** For a revision conflict: the caller's workspace diverged, not the project content. */ + half?: 'workspace'; + kind: 'deleted' | 'revision'; + serverRevision?: number; + }; } export interface ProjectSyncSnapshot { diff --git a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts index 0651ad9d91b..2f4c1c0d54b 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.browser.test.ts @@ -78,10 +78,13 @@ beforeEach(() => { created_at: now, data: serializeProjectDocumentV3(project), minimum_canvas_schema_version: 3, + minimum_document_schema_version: 3, name: project.name, project_id: project.id, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }); void api.saveSession(stateWith([project]), 'tab-a', {}); }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts index 1dfc834b077..100e9e57a30 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/unloadJournal.scenarios.ts @@ -5,12 +5,13 @@ import { accountLifecycle, captureAccountScope } from '@platform/state/accountLi import { createDraftProject, createInitialWorkbenchState } from '@workbench/workbenchState'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ProjectRecordDTO, ProjectSummaryDTO } from './api'; +import type { ProjectWithWorkspaceDTO, ProjectSummaryDTO } from './api'; import type { ProjectDraftStore, ProjectUnloadJournalEntry } from './draftStore'; import type { WorkbenchSessionBlob } from './session'; import { createDurableSyncedWorkbenchPersistence, type DurableProjectPersistenceApi } from './durableSyncedPersistence'; import { serializeProjectDocumentV3 } from './projectDocument'; +import { splitProjectDocument } from './projectHalves'; import { serializeSessionBlob } from './session'; import { getProjectSyncSnapshot } from './syncStore'; @@ -18,11 +19,12 @@ const now = '2026-10-05T00:00:00.000Z'; /** An in-memory project server; `offline` fails writes as an unreachable server does, and `gate` holds updates. */ export const createUnloadJournalServer = () => { - const records = new Map(); + const records = new Map(); let session: WorkbenchSessionBlob | null = null; const server = { /** Applies an update but never answers it, as when the response is lost. */ dropNextUpdateResponse: false, + dropNextWorkspaceResponse: false, gate: null as Promise | null, offline: false, records, @@ -30,6 +32,7 @@ export const createUnloadJournalServer = () => { return session; }, updates: 0, + workspaceUpdates: 0, }; const write = (apply: () => T): Promise => server.offline ? Promise.reject(new TypeError('Failed to fetch')) : Promise.resolve(apply()); @@ -37,15 +40,18 @@ export const createUnloadJournalServer = () => { const api: DurableProjectPersistenceApi = { createProject: (request) => write(() => { - const record: ProjectRecordDTO = { + const record: ProjectWithWorkspaceDTO = { board_id: `board-${request.project_id!}`, created_at: now, data: structuredClone(request.data), minimum_canvas_schema_version: request.minimum_canvas_schema_version ?? 3, + minimum_document_schema_version: request.minimum_document_schema_version ?? 3, name: request.name, project_id: request.project_id!, revision: 1, updated_at: now, + workspace_updated_at: null, + workspace: null, }; records.set(record.project_id, record); return structuredClone(record); @@ -61,6 +67,30 @@ export const createUnloadJournalServer = () => { [...records.values()].map(({ data: _data, ...summary }): ProjectSummaryDTO => structuredClone(summary)) ), loadSession: () => Promise.resolve(session && structuredClone(session)), + saveProjectWorkspace: async (projectId, request) => { + server.workspaceUpdates += 1; + await server.gate; + return write(() => { + const current = records.get(projectId); + if (!current) { + throw notFound(); + } + if ((current.workspace?.revision ?? null) !== request.expected_revision) { + throw Object.assign(new Error('conflict'), { status: 409 }); + } + const workspace = { + data: structuredClone(request.data), + revision: (current.workspace?.revision ?? 0) + 1, + updated_at: now, + }; + records.set(projectId, { ...current, workspace, workspace_updated_at: now }); + if (server.dropNextWorkspaceResponse) { + server.dropNextWorkspaceResponse = false; + throw new TypeError('Failed to fetch'); + } + return structuredClone(workspace); + }); + }, saveSession: (state, editorSessionId, draftEditorSessionIds) => write(() => { session = JSON.parse( @@ -83,6 +113,10 @@ export const createUnloadJournalServer = () => { const record = { ...current, data: structuredClone(request.data), + minimum_document_schema_version: Math.max( + current.minimum_document_schema_version, + request.minimum_document_schema_version ?? current.minimum_document_schema_version + ), name: request.name, revision: current.revision + 1, }; @@ -148,15 +182,19 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { copiesStarted += 1; await copyGate; - return api.createProject( - { - data: input.document, - minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, - name: input.name, - project_id: input.copyProjectId, - }, - input.owner - ); + return { + ...(await api.createProject( + { + data: input.document, + minimum_canvas_schema_version: input.minimumCanvasSchemaVersion, + name: input.name, + project_id: input.copyProjectId, + }, + input.owner + )), + // A copy of a whole document: no workspace until its first save splits it. + workspace: null, + }; }, writerToken: `writer-${writer}`, }); @@ -166,6 +204,15 @@ export const testUnloadJournalScenarios = (createStore: () => Promise stateWith(state.projects.map((candidate) => (candidate.id === project.id ? { ...candidate, name } : candidate))); + /** A change to how one works on the project: its workspace alone. */ + const prompted = (state: WorkbenchState, prompt: string): WorkbenchState => + stateWith( + state.projects.map((candidate) => + candidate.id === project.id + ? { ...candidate, promptHistory: [{ negativePrompt: null, positivePrompt: prompt }] } + : candidate + ) + ); const serverName = () => server.records.get(project.id)?.name; /** Another tab's save, as the server sees it. */ const saveElsewhere = (name: string, revision: number) => { @@ -203,15 +250,20 @@ export const testUnloadJournalScenarios = (createStore: () => Promise, minimum_canvas_schema_version: 3, + minimum_document_schema_version: 4, name: project.name, project_id: project.id, revision: 1, updated_at: now, + workspace: { data: halves.workspace as unknown as Record, revision: 1, updated_at: now }, + workspace_updated_at: now, }); await api.saveSession(stateWith([project]), 'tab-a', {}); }); @@ -591,6 +643,32 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { + const tab = openTab(); + const loaded = await tab.loadWorkbench(); + let release!: () => void; + server.gate = new Promise((resolve) => { + release = resolve; + }); + const saving = tab.saveWorkbench(prompted(loaded.state, 'Saved')); + await vi.waitFor(() => expect(server.workspaceUpdates).toBe(1)); + tab.journalBeforeUnload(prompted(loaded.state, 'Typed while it saved')); + server.gate = null; + release(); + await saving; + // The page unloads before another save. + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + await reloaded.saveWorkbench(recovered.state); + expect(server.records.get(project.id)).toMatchObject({ + revision: 1, + workspace: { data: { promptHistory: [{ positivePrompt: 'Typed while it saved' }] }, revision: 3 }, + }); + }); + it('does not bring back an acknowledged journal whose deletion failed', async () => { const tab = openTab(); const loaded = await tab.loadWorkbench(); @@ -639,6 +717,22 @@ export const testUnloadJournalScenarios = (createStore: () => Promise { + const tab = openTab(); + const loaded = await tab.loadWorkbench(); + server.dropNextWorkspaceResponse = true; + await tab.saveWorkbench(prompted(loaded.state, 'Saved, answer lost')); + expect(server.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 2 } }); + tab.journalBeforeUnload(prompted(loaded.state, 'Typed after it')); + + const reloaded = openTab(); + const recovered = await reloaded.loadWorkbench(); + + expect(recovered.conflicts).toEqual([]); + await reloaded.saveWorkbench(recovered.state); + expect(server.records.get(project.id)).toMatchObject({ revision: 1, workspace: { revision: 3 } }); + }); + it('still raises a conflict when the server moved past an unanswered save to something else', async () => { const tab = openTab(); const loaded = await tab.loadWorkbench(); diff --git a/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts b/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts index 7ec77e8e146..ff37173c9e1 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/useProjectFileActions.ts @@ -11,7 +11,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ProjectRecordDTO } from './api'; +import type { ProjectWithWorkspaceDTO } from './api'; import type { DuplicatedProject } from './library'; import type { ProjectFileDirection } from './projectFileErrors'; @@ -60,7 +60,9 @@ const runReported = async ( }; /** Picker dismissal resolves without work or a toast. */ -export const useImportProjectFile = (onImported: (record: ProjectRecordDTO) => Promise | void): (() => void) => { +export const useImportProjectFile = ( + onImported: (record: ProjectWithWorkspaceDTO) => Promise | void +): (() => void) => { const { t } = useTranslation(); const { requestReferencesOnlyImport } = useProjectFileOptions(); const queryClient = useQueryClient(); diff --git a/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx index 966618055b8..06692c8601a 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/ProjectConflictBanner.browser.test.tsx @@ -15,7 +15,7 @@ const harness = vi.hoisted(() => ({ closeProject: vi.fn<() => ProjectCommandResult>(() => ({ ok: true })), conflict: undefined as | { detectedAt: string; kind: 'deleted' } - | { detectedAt: string; kind: 'revision'; serverRevision: number } + | { detectedAt: string; half?: 'workspace'; kind: 'revision'; serverRevision: number } | undefined, createProject: vi.fn(), deleteRecoverableDraft: vi.fn(() => Promise.resolve()), @@ -50,15 +50,18 @@ vi.mock('@platform/browser/downloadBlob', () => ({ downloadText: harness.downloa // it closes after confirming even when the confirmation fails. vi.mock('@platform/ui/ConfirmDialog', () => ({ ConfirmDialog: ({ + body, isOpen, onClose, onConfirm, }: { + body: string; isOpen: boolean; onClose(): void; onConfirm(): Promise | void; }) => (
+ {isOpen ?

{body}

: null} {isOpen ? (