Skip to content

Commit 511d76e

Browse files
authored
Merge pull request #221 from itsmeakhil/akhil/optimise-backend-v2
Akhil/optimise backend v2
2 parents f306b53 + baa655a commit 511d76e

81 files changed

Lines changed: 3751 additions & 1583 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@ apps/web/.codex
6969
apps/desktop/src-tauri/target/debug/*
7070
apps/desktop/src-tauri/target/release/
7171
.claude
72+
/docs
7273

73-
74+
.superpowers

apps/backend/app/api/routes/api_client/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
ApiClientHistoryOut,
1414
HISTORY_MAX_ITEMS,
1515
)
16+
from app.api.routes.api_client import collections_delta
1617

1718

1819
router = APIRouter(prefix="/api-client", tags=["api-client"])
20+
router.include_router(collections_delta.router)
1921

2022

2123
@router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections")
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Service + route for the collection-items delta endpoint.
2+
3+
POST /api-client/collections/{collection_id}/items:apply-delta
4+
5+
Applies a list of ops (add / update / delete / move) to a collection's items
6+
tree in-memory, then persists the result in a single atomic update_one call.
7+
Cache is invalidated via bump_version exactly as the existing patch_collection
8+
service does.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Any
13+
14+
from bson import ObjectId
15+
from bson.errors import InvalidId
16+
from fastapi import APIRouter, Depends, HTTPException, status
17+
from pymongo import ReturnDocument
18+
from pymongo.errors import PyMongoError
19+
20+
from app.api.routes.auth.services import get_current_uid
21+
from app.api.routes.api_client.schema import (
22+
AddItemOp,
23+
ApplyDeltaRequest,
24+
ApplyDeltaResponse,
25+
ApiClientCollectionOut,
26+
DeleteItemOp,
27+
MoveItemOp,
28+
Op,
29+
UpdateItemOp,
30+
)
31+
from app.core.cache import bump_version
32+
from app.database import db_manager
33+
from app.utils.collection_name import API_CLIENT_COLLECTIONS
34+
35+
router = APIRouter()
36+
37+
38+
def _parse_oid(raw: str, *, kind: str) -> ObjectId:
39+
try:
40+
return ObjectId(raw)
41+
except InvalidId as exc:
42+
raise HTTPException(
43+
status_code=status.HTTP_400_BAD_REQUEST,
44+
detail=f"Invalid {kind} id.",
45+
) from exc
46+
47+
48+
def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut:
49+
oid = doc.get("_id")
50+
return ApiClientCollectionOut(
51+
id=str(oid) if oid is not None else "",
52+
name=doc.get("name", ""),
53+
items=list(doc.get("items") or []),
54+
)
55+
56+
57+
# ── In-memory tree walkers ────────────────────────────────────────────────────
58+
59+
def _apply_add(
60+
items: list[dict[str, Any]],
61+
parent_id: str,
62+
collection_id: str,
63+
new_item: dict[str, Any],
64+
position: int | None,
65+
) -> list[dict[str, Any]]:
66+
"""Insert new_item under parent_id. parent_id may be the collection root."""
67+
if parent_id == collection_id:
68+
# Insert at root level
69+
if position is None or position >= len(items):
70+
return items + [new_item]
71+
result = list(items)
72+
result.insert(position, new_item)
73+
return result
74+
75+
return _add_in_children(items, parent_id, new_item, position)
76+
77+
78+
def _add_in_children(
79+
items: list[dict[str, Any]],
80+
parent_id: str,
81+
new_item: dict[str, Any],
82+
position: int | None,
83+
) -> list[dict[str, Any]]:
84+
result = []
85+
for item in items:
86+
if item.get("id") == parent_id and item.get("type") == "folder":
87+
children = list(item.get("items") or [])
88+
if position is None or position >= len(children):
89+
children = children + [new_item]
90+
else:
91+
children.insert(position, new_item)
92+
result.append({**item, "items": children})
93+
elif item.get("type") == "folder":
94+
result.append({**item, "items": _add_in_children(item.get("items") or [], parent_id, new_item, position)})
95+
else:
96+
result.append(item)
97+
return result
98+
99+
100+
def _apply_update(
101+
items: list[dict[str, Any]],
102+
item_id: str,
103+
patch: dict[str, Any],
104+
) -> tuple[list[dict[str, Any]], bool]:
105+
"""Recursively apply patch to the item with item_id. Returns (new_items, found)."""
106+
result = []
107+
found = False
108+
for item in items:
109+
if item.get("id") == item_id:
110+
result.append({**item, **patch})
111+
found = True
112+
elif item.get("type") == "folder":
113+
new_children, child_found = _apply_update(item.get("items") or [], item_id, patch)
114+
result.append({**item, "items": new_children})
115+
if child_found:
116+
found = True
117+
else:
118+
result.append(item)
119+
return result, found
120+
121+
122+
def _apply_delete(
123+
items: list[dict[str, Any]],
124+
item_id: str,
125+
) -> tuple[list[dict[str, Any]], bool]:
126+
"""Recursively remove item_id. Returns (new_items, found)."""
127+
new_items = []
128+
found = False
129+
for item in items:
130+
if item.get("id") == item_id:
131+
found = True
132+
# skip (delete)
133+
elif item.get("type") == "folder":
134+
new_children, child_found = _apply_delete(item.get("items") or [], item_id)
135+
new_items.append({**item, "items": new_children})
136+
if child_found:
137+
found = True
138+
else:
139+
new_items.append(item)
140+
return new_items, found
141+
142+
143+
def _extract_item(
144+
items: list[dict[str, Any]],
145+
item_id: str,
146+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
147+
"""Remove and return item_id from the tree."""
148+
new_items = []
149+
extracted: dict[str, Any] | None = None
150+
for item in items:
151+
if item.get("id") == item_id:
152+
extracted = item
153+
elif item.get("type") == "folder":
154+
new_children, child_extracted = _extract_item(item.get("items") or [], item_id)
155+
new_items.append({**item, "items": new_children})
156+
if child_extracted is not None:
157+
extracted = child_extracted
158+
else:
159+
new_items.append(item)
160+
return new_items, extracted
161+
162+
163+
def _id_exists_in_tree(items: list[dict[str, Any]], node_id: str) -> bool:
164+
"""Return True if node_id appears anywhere in the tree (any level)."""
165+
for item in items:
166+
if item.get("id") == node_id:
167+
return True
168+
if item.get("type") == "folder":
169+
if _id_exists_in_tree(item.get("items") or [], node_id):
170+
return True
171+
return False
172+
173+
174+
def _apply_move(
175+
items: list[dict[str, Any]],
176+
item_id: str,
177+
new_parent_id: str,
178+
new_index: int,
179+
collection_id: str,
180+
) -> tuple[list[dict[str, Any]], bool]:
181+
"""Move item_id to new_parent_id at new_index. Returns (new_items, success)."""
182+
# Validate that new_parent_id exists (root or a folder in the tree).
183+
if new_parent_id != collection_id and not _id_exists_in_tree(items, new_parent_id):
184+
raise HTTPException(
185+
status_code=status.HTTP_400_BAD_REQUEST,
186+
detail="move: new_parent_id not found",
187+
)
188+
189+
# Step 1: extract the item
190+
items_without, target = _extract_item(items, item_id)
191+
if target is None:
192+
return items, False
193+
194+
# Step 2: insert at new parent
195+
new_items = _apply_add(items_without, new_parent_id, collection_id, target, new_index)
196+
return new_items, True
197+
198+
199+
# ── Service ───────────────────────────────────────────────────────────────────
200+
201+
async def apply_collection_delta(
202+
uid: str,
203+
collection_id: str,
204+
ops: list[Op],
205+
) -> ApiClientCollectionOut:
206+
oid = _parse_oid(collection_id, kind="collection")
207+
208+
# Fetch + ownership check BEFORE any mutation
209+
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
210+
if not doc:
211+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
212+
213+
items: list[dict[str, Any]] = list(doc.get("items") or [])
214+
215+
# Apply all ops in-memory (atomic within a single request)
216+
for op in ops:
217+
if isinstance(op, AddItemOp):
218+
items = _apply_add(items, op.parent_id, collection_id, op.item, op.position)
219+
220+
elif isinstance(op, UpdateItemOp):
221+
items, found = _apply_update(items, op.item_id, op.patch)
222+
if not found:
223+
raise HTTPException(
224+
status_code=status.HTTP_404_NOT_FOUND,
225+
detail=f"Item {op.item_id!r} not found.",
226+
)
227+
228+
elif isinstance(op, DeleteItemOp):
229+
items, found = _apply_delete(items, op.item_id)
230+
if not found:
231+
raise HTTPException(
232+
status_code=status.HTTP_404_NOT_FOUND,
233+
detail=f"Item {op.item_id!r} not found.",
234+
)
235+
236+
elif isinstance(op, MoveItemOp):
237+
items, ok = _apply_move(items, op.item_id, op.new_parent_id, op.new_index, collection_id)
238+
if not ok:
239+
raise HTTPException(
240+
status_code=status.HTTP_404_NOT_FOUND,
241+
detail=f"Item {op.item_id!r} not found.",
242+
)
243+
244+
# Single atomic update
245+
try:
246+
updated_doc = await db_manager.find_one_and_update(
247+
API_CLIENT_COLLECTIONS,
248+
{"_id": oid, "created_by": uid},
249+
{"$set": {"items": items}},
250+
return_document=ReturnDocument.AFTER,
251+
)
252+
except PyMongoError as exc:
253+
raise HTTPException(
254+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
255+
detail="Failed to update collection.",
256+
) from exc
257+
258+
if not updated_doc:
259+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
260+
261+
# Invalidate cache (same namespace as patch_collection / delete_collection)
262+
await bump_version(ns="api_client", uid=uid)
263+
264+
return _collection_to_out(updated_doc)
265+
266+
267+
# ── Route ─────────────────────────────────────────────────────────────────────
268+
269+
@router.post(
270+
"/collections/{collection_id}/items:apply-delta",
271+
response_model=ApplyDeltaResponse,
272+
summary="Apply delta ops to a collection's items tree",
273+
)
274+
async def apply_delta(
275+
collection_id: str,
276+
body: ApplyDeltaRequest,
277+
uid: str = Depends(get_current_uid),
278+
) -> ApplyDeltaResponse:
279+
collection = await apply_collection_delta(uid, collection_id, body.ops)
280+
return ApplyDeltaResponse(collection=collection)

apps/backend/app/api/routes/api_client/schema.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Optional
1+
from typing import Annotated, Any, Literal, Optional, Union
22

33
from pydantic import BaseModel, ConfigDict, Field
44

@@ -50,6 +50,47 @@ class ApiClientEnvironmentOut(ApiClientEnvironmentBase):
5050
HISTORY_MAX_ITEMS = 100
5151

5252

53+
# ── Delta ops ────────────────────────────────────────────────────────────────
54+
55+
class AddItemOp(BaseModel):
56+
type: Literal["add"]
57+
parent_id: str
58+
item: dict[str, Any]
59+
position: Optional[int] = None
60+
61+
62+
class UpdateItemOp(BaseModel):
63+
type: Literal["update"]
64+
item_id: str
65+
patch: dict[str, Any]
66+
67+
68+
class DeleteItemOp(BaseModel):
69+
type: Literal["delete"]
70+
item_id: str
71+
72+
73+
class MoveItemOp(BaseModel):
74+
type: Literal["move"]
75+
item_id: str
76+
new_parent_id: str
77+
new_index: int
78+
79+
80+
Op = Annotated[
81+
Union[AddItemOp, UpdateItemOp, DeleteItemOp, MoveItemOp],
82+
Field(discriminator="type"),
83+
]
84+
85+
86+
class ApplyDeltaRequest(BaseModel):
87+
ops: list[Op]
88+
89+
90+
class ApplyDeltaResponse(BaseModel):
91+
collection: ApiClientCollectionOut
92+
93+
5394
class ApiClientHistoryCreate(BaseModel):
5495
"""One sent request snapshot (matches web HistoryRequest minus id)."""
5596

apps/backend/tests/api/routes/api_client/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)