Skip to content

Commit 13c2bc2

Browse files
committed
feat(flow): add collaborative editing database foundation
Add flow.latest_operation_revision and the flow_operation table for durable, revision-ordered operation batches. Persist forward_ops and backward_ops with actor_user_id and actor_delegate (self/agent), plus CRUD helpers, Alembic migration, and unit tests.
1 parent cb7b838 commit 13c2bc2

7 files changed

Lines changed: 482 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Add flow.latest_operation_revision and flow_operation table for collaborative editing.
2+
3+
Phase: EXPAND
4+
Revision ID: e8f1a2b3c4d5
5+
Revises: 7c8d9e0f1a2b
6+
Create Date: 2026-05-28 00:00:00.000000
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from langflow.utils import migration
14+
15+
revision: str = "e8f1a2b3c4d5" # pragma: allowlist secret
16+
down_revision: str | None = "7c8d9e0f1a2b" # pragma: allowlist secret
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
conn = op.get_bind()
23+
24+
with op.batch_alter_table("flow", schema=None) as batch_op:
25+
if not migration.column_exists(table_name="flow", column_name="latest_operation_revision", conn=conn):
26+
batch_op.add_column(
27+
sa.Column("latest_operation_revision", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
28+
)
29+
batch_op.create_index(
30+
op.f("ix_flow_latest_operation_revision"),
31+
["latest_operation_revision"],
32+
unique=False,
33+
)
34+
35+
if not migration.table_exists("flow_operation", conn):
36+
op.create_table(
37+
"flow_operation",
38+
sa.Column("id", sa.Uuid(), nullable=False),
39+
sa.Column("flow_id", sa.Uuid(), nullable=False),
40+
sa.Column("protocol_version", sa.Integer(), nullable=False),
41+
sa.Column("revision", sa.BigInteger(), nullable=False),
42+
sa.Column("client_id", sa.String(), nullable=False),
43+
sa.Column("actor_user_id", sa.Uuid(), nullable=True),
44+
sa.Column("actor_delegate", sa.String(), server_default="self", nullable=False),
45+
sa.Column("forward_ops", sa.JSON(), nullable=False),
46+
sa.Column("backward_ops", sa.JSON(), nullable=False),
47+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
48+
sa.ForeignKeyConstraint(["actor_user_id"], ["user.id"], ondelete="SET NULL"),
49+
sa.ForeignKeyConstraint(["flow_id"], ["flow.id"], ondelete="CASCADE"),
50+
sa.PrimaryKeyConstraint("id"),
51+
sa.UniqueConstraint("flow_id", "revision", name="unique_flow_operation_revision"),
52+
)
53+
op.create_index(op.f("ix_flow_operation_flow_id"), "flow_operation", ["flow_id"])
54+
op.create_index(op.f("ix_flow_operation_actor_user_id"), "flow_operation", ["actor_user_id"])
55+
op.create_index(
56+
"ix_flow_operation_flow_id_created_at",
57+
"flow_operation",
58+
["flow_id", "created_at"],
59+
)
60+
61+
62+
def downgrade() -> None:
63+
conn = op.get_bind()
64+
65+
if migration.table_exists("flow_operation", conn):
66+
op.drop_index("ix_flow_operation_flow_id_created_at", table_name="flow_operation")
67+
op.drop_index(op.f("ix_flow_operation_actor_user_id"), table_name="flow_operation")
68+
op.drop_index(op.f("ix_flow_operation_flow_id"), table_name="flow_operation")
69+
op.drop_table("flow_operation")
70+
71+
with op.batch_alter_table("flow", schema=None) as batch_op:
72+
if migration.column_exists(table_name="flow", column_name="latest_operation_revision", conn=conn):
73+
batch_op.drop_index(op.f("ix_flow_latest_operation_revision"))
74+
batch_op.drop_column("latest_operation_revision")

src/backend/base/langflow/services/database/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .deployment_provider_account import DeploymentProviderAccount
1616
from .file import File
1717
from .flow import Flow
18+
from .flow_operation import FlowOperation
1819
from .flow_version import FlowVersion
1920
from .flow_version_deployment_attachment import FlowVersionDeploymentAttachment
2021
from .folder import Folder
@@ -42,6 +43,7 @@
4243
"DeploymentProviderAccount",
4344
"File",
4445
"Flow",
46+
"FlowOperation",
4547
"FlowVersion",
4648
"FlowVersionDeploymentAttachment",
4749
"Folder",

src/backend/base/langflow/services/database/models/flow/model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from emoji import purely_emoji
1111
from lfx.log.logger import logger
1212
from pydantic import BaseModel, ValidationInfo, field_serializer, field_validator
13+
from sqlalchemy import BigInteger, Text, UniqueConstraint, text
1314
from sqlalchemy import Enum as SQLEnum
14-
from sqlalchemy import Text, UniqueConstraint, text
1515
from sqlmodel import JSON, Column, Field, Relationship, SQLModel
1616

1717
from langflow.schema.data import Data
@@ -190,6 +190,10 @@ def validate_dt(cls, v):
190190

191191
class Flow(FlowBase, table=True): # type: ignore[call-arg]
192192
id: UUID = Field(default_factory=uuid4, primary_key=True, unique=True)
193+
latest_operation_revision: int = Field(
194+
default=0,
195+
sa_column=Column(BigInteger, nullable=False, server_default=text("0"), index=True),
196+
)
193197
data: dict | None = Field(default=None, sa_column=Column(JSON))
194198
user_id: UUID | None = Field(index=True, foreign_key="user.id", nullable=True)
195199
user: "User" = Relationship(back_populates="flows")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from .crud import (
2+
create_flow_operation,
3+
get_flow_operation_by_revision,
4+
list_flow_operations_after_revision,
5+
)
6+
from .model import FlowOperation, FlowOperationActorDelegate, FlowOperationRead
7+
8+
__all__ = [
9+
"FlowOperation",
10+
"FlowOperationActorDelegate",
11+
"FlowOperationRead",
12+
"create_flow_operation",
13+
"get_flow_operation_by_revision",
14+
"list_flow_operations_after_revision",
15+
]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any
4+
5+
from sqlmodel import col, select
6+
7+
from langflow.services.database.models.flow_operation.model import FlowOperation, FlowOperationActorDelegate
8+
9+
if TYPE_CHECKING:
10+
from uuid import UUID
11+
12+
from sqlmodel.ext.asyncio.session import AsyncSession
13+
14+
15+
async def create_flow_operation(
16+
session: AsyncSession,
17+
*,
18+
flow_id: UUID,
19+
protocol_version: int,
20+
revision: int,
21+
client_id: str,
22+
actor_user_id: UUID,
23+
actor_delegate: FlowOperationActorDelegate,
24+
forward_ops: list[dict[str, Any]],
25+
backward_ops: list[dict[str, Any]],
26+
) -> FlowOperation:
27+
"""Persist an accepted operation batch row.
28+
29+
``actor_user_id`` must be the authenticated user that initiated the operation,
30+
including agent operations started by a user. ``actor_delegate`` distinguishes
31+
direct edits (``self``) from agent-mediated edits (``agent``). ``actor_user_id``
32+
is nullable on the model only so existing operation rows survive user deletion
33+
via ``ON DELETE SET NULL``.
34+
35+
This helper does not authenticate, authorize, or verify that ``actor_user_id``
36+
may write to ``flow_id``. Callers must derive it from authentication, then
37+
load the flow and run the appropriate authorization guard before creating an
38+
operation row.
39+
"""
40+
entry = FlowOperation(
41+
flow_id=flow_id,
42+
protocol_version=protocol_version,
43+
revision=revision,
44+
client_id=client_id,
45+
actor_user_id=actor_user_id,
46+
actor_delegate=actor_delegate,
47+
forward_ops=forward_ops,
48+
backward_ops=backward_ops,
49+
)
50+
session.add(entry)
51+
await session.flush()
52+
await session.refresh(entry)
53+
return entry
54+
55+
56+
async def get_flow_operation_by_revision(
57+
session: AsyncSession,
58+
flow_id: UUID,
59+
revision: int,
60+
) -> FlowOperation | None:
61+
"""Return an operation row by flow/revision.
62+
63+
Callers are responsible for flow visibility checks before exposing the row.
64+
"""
65+
result = await session.exec(
66+
select(FlowOperation).where(FlowOperation.flow_id == flow_id, FlowOperation.revision == revision)
67+
)
68+
return result.first()
69+
70+
71+
async def list_flow_operations_after_revision(
72+
session: AsyncSession,
73+
flow_id: UUID,
74+
after_revision: int,
75+
*,
76+
page_size: int,
77+
) -> list[FlowOperation]:
78+
"""Return one page of accepted operations with revision strictly greater than ``after_revision``.
79+
80+
``page_size`` is supplied by the API layer (default/max validation belong there, not in CRUD).
81+
Callers are responsible for flow visibility checks before exposing rows.
82+
"""
83+
stmt = (
84+
select(FlowOperation)
85+
.where(FlowOperation.flow_id == flow_id, FlowOperation.revision > after_revision)
86+
.order_by(col(FlowOperation.revision).asc())
87+
.limit(page_size)
88+
)
89+
return list((await session.exec(stmt)).all())
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from enum import Enum
5+
from typing import Any
6+
from uuid import UUID, uuid4
7+
8+
from pydantic import BaseModel, field_serializer
9+
from pydantic import Field as PydanticField
10+
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, String, UniqueConstraint, func
11+
from sqlmodel import JSON, Field, SQLModel
12+
13+
14+
class FlowOperationActorDelegate(str, Enum):
15+
SELF = "self"
16+
AGENT = "agent"
17+
18+
19+
class FlowOperation(SQLModel, table=True): # type: ignore[call-arg]
20+
"""Durable record of an accepted collaborative operation batch for a flow."""
21+
22+
__tablename__ = "flow_operation"
23+
__mapper_args__ = {"confirm_deleted_rows": False}
24+
25+
id: UUID = Field(default_factory=uuid4, primary_key=True)
26+
flow_id: UUID = Field(
27+
sa_column=Column(ForeignKey("flow.id", ondelete="CASCADE"), nullable=False, index=True),
28+
)
29+
protocol_version: int = Field(nullable=False)
30+
revision: int = Field(sa_column=Column(BigInteger, nullable=False))
31+
client_id: str = Field(sa_column=Column(String, nullable=False))
32+
actor_user_id: UUID | None = Field(
33+
sa_column=Column(ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True),
34+
)
35+
actor_delegate: FlowOperationActorDelegate = Field(
36+
default=FlowOperationActorDelegate.SELF,
37+
sa_column=Column(String, nullable=False, server_default=FlowOperationActorDelegate.SELF.value),
38+
)
39+
forward_ops: list[dict[str, Any]] = Field(sa_column=Column(JSON, nullable=False))
40+
backward_ops: list[dict[str, Any]] = Field(sa_column=Column(JSON, nullable=False))
41+
created_at: datetime = Field(
42+
sa_column=Column(DateTime(timezone=True), server_default=func.now(), nullable=False),
43+
)
44+
45+
__table_args__ = (UniqueConstraint("flow_id", "revision", name="unique_flow_operation_revision"),)
46+
47+
48+
class FlowOperationRead(BaseModel):
49+
"""Compact operation row for polling APIs."""
50+
51+
operation_id: UUID = PydanticField(description="Server-generated operation identity")
52+
protocol_version: int
53+
revision: int = PydanticField(ge=0)
54+
client_id: str
55+
actor_delegate: FlowOperationActorDelegate
56+
# Nullable only for historical rows after the actor user has been deleted.
57+
actor_user_id: UUID | None = None
58+
forward_ops: list[dict[str, Any]]
59+
created_at: datetime
60+
61+
@field_serializer("created_at")
62+
def serialize_created_at(self, value: datetime) -> str:
63+
value = value.replace(microsecond=0)
64+
if value.tzinfo is None:
65+
value = value.replace(tzinfo=timezone.utc)
66+
return value.isoformat()

0 commit comments

Comments
 (0)