Files
wehub-resource-sync 555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

500 lines
17 KiB
Python

from datetime import UTC, datetime
import io
import json
import gzip
import zipfile
from typing import Optional, List, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.database import get_db
from app.models import (
User, App, Memory, MemoryState, Category, memory_categories,
MemoryStatusHistory, AccessControl
)
from app.utils.memory import get_memory_client
from uuid import uuid4
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
class ExportRequest(BaseModel):
user_id: str
app_id: Optional[UUID] = None
from_date: Optional[int] = None
to_date: Optional[int] = None
include_vectors: bool = True
def _iso(dt: Optional[datetime]) -> Optional[str]:
if isinstance(dt, datetime):
try:
return dt.astimezone(UTC).isoformat()
except:
return dt.replace(tzinfo=UTC).isoformat()
return None
def _parse_iso(dt: Optional[str]) -> Optional[datetime]:
if not dt:
return None
try:
return datetime.fromisoformat(dt)
except Exception:
try:
return datetime.fromisoformat(dt.replace("Z", "+00:00"))
except Exception:
return None
def _export_sqlite(db: Session, req: ExportRequest) -> Dict[str, Any]:
user = db.query(User).filter(User.user_id == req.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
time_filters = []
if req.from_date:
time_filters.append(Memory.created_at >= datetime.fromtimestamp(req.from_date, tz=UTC))
if req.to_date:
time_filters.append(Memory.created_at <= datetime.fromtimestamp(req.to_date, tz=UTC))
mem_q = (
db.query(Memory)
.options(joinedload(Memory.categories), joinedload(Memory.app))
.filter(
Memory.user_id == user.id,
*(time_filters or []),
* ( [Memory.app_id == req.app_id] if req.app_id else [] ),
)
)
memories = mem_q.all()
memory_ids = [m.id for m in memories]
app_ids = sorted({m.app_id for m in memories if m.app_id})
apps = db.query(App).filter(App.id.in_(app_ids)).all() if app_ids else []
cats = sorted({c for m in memories for c in m.categories}, key = lambda c: str(c.id))
mc_rows = db.execute(
memory_categories.select().where(memory_categories.c.memory_id.in_(memory_ids))
).fetchall() if memory_ids else []
history = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.memory_id.in_(memory_ids)).all() if memory_ids else []
acls = db.query(AccessControl).filter(
AccessControl.subject_type == "app",
AccessControl.subject_id.in_(app_ids) if app_ids else False
).all() if app_ids else []
return {
"user": {
"id": str(user.id),
"user_id": user.user_id,
"name": user.name,
"email": user.email,
"metadata": user.metadata_,
"created_at": _iso(user.created_at),
"updated_at": _iso(user.updated_at)
},
"apps": [
{
"id": str(a.id),
"owner_id": str(a.owner_id),
"name": a.name,
"description": a.description,
"metadata": a.metadata_,
"is_active": a.is_active,
"created_at": _iso(a.created_at),
"updated_at": _iso(a.updated_at),
}
for a in apps
],
"categories": [
{
"id": str(c.id),
"name": c.name,
"description": c.description,
"created_at": _iso(c.created_at),
"updated_at": _iso(c.updated_at),
}
for c in cats
],
"memories": [
{
"id": str(m.id),
"user_id": str(m.user_id),
"app_id": str(m.app_id) if m.app_id else None,
"content": m.content,
"metadata": m.metadata_,
"state": m.state.value,
"created_at": _iso(m.created_at),
"updated_at": _iso(m.updated_at),
"archived_at": _iso(m.archived_at),
"deleted_at": _iso(m.deleted_at),
"category_ids": [str(c.id) for c in m.categories], #TODO: figure out a way to add category names simply to this
}
for m in memories
],
"memory_categories": [
{"memory_id": str(r.memory_id), "category_id": str(r.category_id)}
for r in mc_rows
],
"status_history": [
{
"id": str(h.id),
"memory_id": str(h.memory_id),
"changed_by": str(h.changed_by),
"old_state": h.old_state.value,
"new_state": h.new_state.value,
"changed_at": _iso(h.changed_at),
}
for h in history
],
"access_controls": [
{
"id": str(ac.id),
"subject_type": ac.subject_type,
"subject_id": str(ac.subject_id) if ac.subject_id else None,
"object_type": ac.object_type,
"object_id": str(ac.object_id) if ac.object_id else None,
"effect": ac.effect,
"created_at": _iso(ac.created_at),
}
for ac in acls
],
"export_meta": {
"app_id_filter": str(req.app_id) if req.app_id else None,
"from_date": req.from_date,
"to_date": req.to_date,
"version": "1",
"generated_at": datetime.now(UTC).isoformat(),
},
}
def _export_logical_memories_gz(
db: Session,
*,
user_id: str,
app_id: Optional[UUID] = None,
from_date: Optional[int] = None,
to_date: Optional[int] = None
) -> bytes:
"""
Export a provider-agnostic backup of memories so they can be restored to any vector DB
by re-embedding content. One JSON object per line, gzip-compressed.
Schema (per line):
{
"id": "<uuid>",
"content": "<text>",
"metadata": {...},
"created_at": "<iso8601 or null>",
"updated_at": "<iso8601 or null>",
"state": "active|paused|archived|deleted",
"app": "<app name or null>",
"categories": ["catA", "catB", ...]
}
"""
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
time_filters = []
if from_date:
time_filters.append(Memory.created_at >= datetime.fromtimestamp(from_date, tz=UTC))
if to_date:
time_filters.append(Memory.created_at <= datetime.fromtimestamp(to_date, tz=UTC))
q = (
db.query(Memory)
.options(joinedload(Memory.categories), joinedload(Memory.app))
.filter(
Memory.user_id == user.id,
*(time_filters or []),
)
)
if app_id:
q = q.filter(Memory.app_id == app_id)
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
for m in q.all():
record = {
"id": str(m.id),
"content": m.content,
"metadata": m.metadata_ or {},
"created_at": _iso(m.created_at),
"updated_at": _iso(m.updated_at),
"state": m.state.value,
"app": m.app.name if m.app else None,
"categories": [c.name for c in m.categories],
}
gz.write((json.dumps(record) + "\n").encode("utf-8"))
return buf.getvalue()
@router.post("/export")
async def export_backup(req: ExportRequest, db: Session = Depends(get_db)):
sqlite_payload = _export_sqlite(db=db, req=req)
memories_blob = _export_logical_memories_gz(
db=db,
user_id=req.user_id,
app_id=req.app_id,
from_date=req.from_date,
to_date=req.to_date,
)
#TODO: add vector store specific exports in future for speed
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("memories.json", json.dumps(sqlite_payload, indent=2))
zf.writestr("memories.jsonl.gz", memories_blob)
zip_buf.seek(0)
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="memories_export_{req.user_id}.zip"'},
)
@router.post("/import")
async def import_backup(
file: UploadFile = File(..., description="Zip with memories.json and memories.jsonl.gz"),
user_id: str = Form(..., description="Import memories into this user_id"),
mode: str = Query("overwrite"),
db: Session = Depends(get_db)
):
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Expected a zip file.")
if mode not in {"skip", "overwrite"}:
raise HTTPException(status_code=400, detail="Invalid mode. Must be 'skip' or 'overwrite'.")
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
content = await file.read()
try:
with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
names = zf.namelist()
def find_member(filename: str) -> Optional[str]:
for name in names:
# Skip directory entries
if name.endswith('/'):
continue
if name.rsplit('/', 1)[-1] == filename:
return name
return None
sqlite_member = find_member("memories.json")
if not sqlite_member:
raise HTTPException(status_code=400, detail="memories.json missing in zip")
memories_member = find_member("memories.jsonl.gz")
sqlite_data = json.loads(zf.read(sqlite_member))
memories_blob = zf.read(memories_member) if memories_member else None
except Exception:
raise HTTPException(status_code=400, detail="Invalid zip file")
default_app = db.query(App).filter(App.owner_id == user.id, App.name == "openmemory").first()
if not default_app:
default_app = App(owner_id=user.id, name="openmemory", is_active=True, metadata_={})
db.add(default_app)
db.commit()
db.refresh(default_app)
cat_id_map: Dict[str, UUID] = {}
for c in sqlite_data.get("categories", []):
cat = db.query(Category).filter(Category.name == c["name"]).first()
if not cat:
cat = Category(name=c["name"], description=c.get("description"))
db.add(cat)
db.commit()
db.refresh(cat)
cat_id_map[c["id"]] = cat.id
old_to_new_id: Dict[str, UUID] = {}
for m in sqlite_data.get("memories", []):
incoming_id = UUID(m["id"])
existing = db.query(Memory).filter(Memory.id == incoming_id).first()
# Cross-user collision: always mint a new UUID and import as a new memory
if existing and existing.user_id != user.id:
target_id = uuid4()
else:
target_id = incoming_id
old_to_new_id[m["id"]] = target_id
# Same-user collision + skip mode: leave existing row untouched
if existing and (existing.user_id == user.id) and mode == "skip":
continue
# Same-user collision + overwrite mode: treat import as ground truth
if existing and (existing.user_id == user.id) and mode == "overwrite":
incoming_state = m.get("state", "active")
existing.user_id = user.id
existing.app_id = default_app.id
existing.content = m.get("content") or ""
existing.metadata_ = m.get("metadata") or {}
try:
existing.state = MemoryState(incoming_state)
except Exception:
existing.state = MemoryState.active
# Update state-related timestamps from import (ground truth)
existing.archived_at = _parse_iso(m.get("archived_at"))
existing.deleted_at = _parse_iso(m.get("deleted_at"))
existing.created_at = _parse_iso(m.get("created_at")) or existing.created_at
existing.updated_at = _parse_iso(m.get("updated_at")) or existing.updated_at
db.add(existing)
db.commit()
continue
new_mem = Memory(
id=target_id,
user_id=user.id,
app_id=default_app.id,
content=m.get("content") or "",
metadata_=m.get("metadata") or {},
state=MemoryState(m.get("state", "active")) if m.get("state") else MemoryState.active,
created_at=_parse_iso(m.get("created_at")) or datetime.now(UTC),
updated_at=_parse_iso(m.get("updated_at")) or datetime.now(UTC),
archived_at=_parse_iso(m.get("archived_at")),
deleted_at=_parse_iso(m.get("deleted_at")),
)
db.add(new_mem)
db.commit()
for link in sqlite_data.get("memory_categories", []):
mid = old_to_new_id.get(link["memory_id"])
cid = cat_id_map.get(link["category_id"])
if not (mid and cid):
continue
exists = db.execute(
memory_categories.select().where(
(memory_categories.c.memory_id == mid) & (memory_categories.c.category_id == cid)
)
).first()
if not exists:
db.execute(memory_categories.insert().values(memory_id=mid, category_id=cid))
db.commit()
for h in sqlite_data.get("status_history", []):
hid = UUID(h["id"])
mem_id = old_to_new_id.get(h["memory_id"], UUID(h["memory_id"]))
exists = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.id == hid).first()
if exists and mode == "skip":
continue
rec = exists if exists else MemoryStatusHistory(id=hid)
rec.memory_id = mem_id
rec.changed_by = user.id
try:
rec.old_state = MemoryState(h.get("old_state", "active"))
rec.new_state = MemoryState(h.get("new_state", "active"))
except Exception:
rec.old_state = MemoryState.active
rec.new_state = MemoryState.active
rec.changed_at = _parse_iso(h.get("changed_at")) or datetime.now(UTC)
db.add(rec)
db.commit()
memory_client = get_memory_client()
vector_store = getattr(memory_client, "vector_store", None) if memory_client else None
if vector_store and memory_client and hasattr(memory_client, "embedding_model"):
def iter_logical_records():
if memories_blob:
gz_buf = io.BytesIO(memories_blob)
with gzip.GzipFile(fileobj=gz_buf, mode="rb") as gz:
for raw in gz:
yield json.loads(raw.decode("utf-8"))
else:
for m in sqlite_data.get("memories", []):
yield {
"id": m["id"],
"content": m.get("content"),
"metadata": m.get("metadata") or {},
"created_at": m.get("created_at"),
"updated_at": m.get("updated_at"),
}
for rec in iter_logical_records():
old_id = rec["id"]
new_id = old_to_new_id.get(old_id, UUID(old_id))
content = rec.get("content") or ""
metadata = rec.get("metadata") or {}
created_at = rec.get("created_at")
updated_at = rec.get("updated_at")
if mode == "skip":
try:
get_fn = getattr(vector_store, "get", None)
if callable(get_fn) and vector_store.get(str(new_id)):
continue
except Exception:
pass
payload = dict(metadata)
payload["data"] = content
if created_at:
payload["created_at"] = created_at
if updated_at:
payload["updated_at"] = updated_at
payload["user_id"] = user_id
payload.setdefault("source_app", "openmemory")
try:
vec = memory_client.embedding_model.embed(content, "add")
vector_store.insert(vectors=[vec], payloads=[payload], ids=[str(new_id)])
except Exception as e:
print(f"Vector upsert failed for memory {new_id}: {e}")
continue
return {"message": f'Import completed into user "{user_id}"'}
return {"message": f'Import completed into user "{user_id}"'}