cognee 1.2.2.dev2__py3-none-any.whl → 1.2.2.dev4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cognee/api/v1/export/export.py +5 -0
- cognee/modules/migration/cogx.py +28 -1
- cognee/modules/migration/export.py +105 -3
- cognee/modules/migration/import_source.py +241 -5
- cognee/modules/migration/loader.py +73 -20
- cognee/modules/migration/sources/base.py +20 -1
- cognee/modules/migration/sources/cogx_archive.py +13 -1
- cognee/tests/test_cogx_roundtrip.py +126 -3
- cognee/tests/test_external_source_import.py +133 -0
- cognee/tests/unit/migration/test_export_roundtrip.py +33 -0
- cognee/tests/unit/migration/test_import_source.py +251 -0
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/METADATA +1 -1
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/RECORD +17 -16
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/WHEEL +0 -0
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/entry_points.txt +0 -0
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/licenses/LICENSE +0 -0
- {cognee-1.2.2.dev2.dist-info → cognee-1.2.2.dev4.dist-info}/licenses/NOTICE.md +0 -0
cognee/api/v1/export/export.py
CHANGED
|
@@ -18,6 +18,7 @@ async def export(
|
|
|
18
18
|
destination: Optional[Union[str, Path]] = None,
|
|
19
19
|
user=None,
|
|
20
20
|
link_relations: bool = False,
|
|
21
|
+
include_permissions: bool = False,
|
|
21
22
|
) -> GraphSnapshot: ...
|
|
22
23
|
|
|
23
24
|
|
|
@@ -28,6 +29,7 @@ async def export(
|
|
|
28
29
|
destination: Optional[Union[str, Path]] = None,
|
|
29
30
|
user=None,
|
|
30
31
|
link_relations: bool = False,
|
|
32
|
+
include_permissions: bool = False,
|
|
31
33
|
) -> ExportResult: ...
|
|
32
34
|
|
|
33
35
|
|
|
@@ -39,6 +41,7 @@ async def export(
|
|
|
39
41
|
destination: Optional[Union[str, Path]] = None,
|
|
40
42
|
user=None,
|
|
41
43
|
link_relations: bool = False,
|
|
44
|
+
include_permissions: bool = False,
|
|
42
45
|
) -> ExportResult: ...
|
|
43
46
|
|
|
44
47
|
|
|
@@ -48,6 +51,7 @@ async def export(
|
|
|
48
51
|
destination: Optional[Union[str, Path]] = None,
|
|
49
52
|
user=None,
|
|
50
53
|
link_relations: bool = False,
|
|
54
|
+
include_permissions: bool = False,
|
|
51
55
|
) -> Union[ExportResult, GraphSnapshot]:
|
|
52
56
|
"""Export a dataset's knowledge graph.
|
|
53
57
|
|
|
@@ -97,4 +101,5 @@ async def export(
|
|
|
97
101
|
destination=destination,
|
|
98
102
|
user=user,
|
|
99
103
|
link_relations=link_relations,
|
|
104
|
+
include_permissions=include_permissions,
|
|
100
105
|
)
|
cognee/modules/migration/cogx.py
CHANGED
|
@@ -169,6 +169,7 @@ RECORD_FILES: Dict[str, str] = {
|
|
|
169
169
|
|
|
170
170
|
MANIFEST_FILE = "manifest.json"
|
|
171
171
|
RAW_NODES_FILE = "nodes.jsonl"
|
|
172
|
+
PERMISSIONS_FILE = "permissions.json"
|
|
172
173
|
|
|
173
174
|
|
|
174
175
|
class COGXManifest(BaseModel):
|
|
@@ -177,6 +178,11 @@ class COGXManifest(BaseModel):
|
|
|
177
178
|
exported_at: Optional[datetime] = None
|
|
178
179
|
counts: Dict[str, int] = Field(default_factory=dict)
|
|
179
180
|
embedding_model: Optional[str] = None
|
|
181
|
+
# Cognee-origin archives only: the source store's stamped data-migration
|
|
182
|
+
# revision at export time. Raw nodes keep their source-store ids, so the
|
|
183
|
+
# importing store must not claim a newer revision than the exported data
|
|
184
|
+
# actually has (None = unknown; the import keeps its own stamp).
|
|
185
|
+
migration_revision: Optional[str] = None
|
|
180
186
|
notes: List[str] = Field(default_factory=list)
|
|
181
187
|
|
|
182
188
|
|
|
@@ -209,12 +215,13 @@ class COGXArchiveWriter:
|
|
|
209
215
|
def __init__(self, directory: Union[str, Path], source_system: str = "cognee"):
|
|
210
216
|
self.directory = Path(directory)
|
|
211
217
|
self.directory.mkdir(parents=True, exist_ok=True)
|
|
212
|
-
for file_name in (*RECORD_FILES.values(), RAW_NODES_FILE, MANIFEST_FILE):
|
|
218
|
+
for file_name in (*RECORD_FILES.values(), RAW_NODES_FILE, MANIFEST_FILE, PERMISSIONS_FILE):
|
|
213
219
|
(self.directory / file_name).unlink(missing_ok=True)
|
|
214
220
|
self.source_system = source_system
|
|
215
221
|
self.counts: Dict[str, int] = {}
|
|
216
222
|
self.notes: List[str] = []
|
|
217
223
|
self.embedding_model: Optional[str] = None
|
|
224
|
+
self.migration_revision: Optional[str] = None
|
|
218
225
|
self._handles: Dict[str, Any] = {}
|
|
219
226
|
|
|
220
227
|
def __enter__(self) -> "COGXArchiveWriter":
|
|
@@ -254,6 +261,7 @@ class COGXArchiveWriter:
|
|
|
254
261
|
exported_at=datetime.now(timezone.utc),
|
|
255
262
|
counts=self.counts,
|
|
256
263
|
embedding_model=self.embedding_model,
|
|
264
|
+
migration_revision=self.migration_revision,
|
|
257
265
|
notes=self.notes,
|
|
258
266
|
)
|
|
259
267
|
manifest_path = self.directory / MANIFEST_FILE
|
|
@@ -269,6 +277,25 @@ def read_manifest(directory: Union[str, Path]) -> Optional[COGXManifest]:
|
|
|
269
277
|
return manifest
|
|
270
278
|
|
|
271
279
|
|
|
280
|
+
def write_social_layer(directory: Union[str, Path], payload: Dict[str, Any]) -> None:
|
|
281
|
+
"""Persist the dataset's social layer (owner + grants, WITH credentials).
|
|
282
|
+
|
|
283
|
+
Written only on explicit request (``export(include_permissions=True)``):
|
|
284
|
+
the payload carries user emails, password HASHES, and account flags, so an
|
|
285
|
+
archive containing this file must be handled as a secret, not just data.
|
|
286
|
+
"""
|
|
287
|
+
(Path(directory) / PERMISSIONS_FILE).write_text(
|
|
288
|
+
json.dumps(payload, indent=2, default=str), encoding="utf-8"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def read_social_layer(directory: Union[str, Path]) -> Optional[Dict[str, Any]]:
|
|
293
|
+
permissions_path = Path(directory) / PERMISSIONS_FILE
|
|
294
|
+
if not permissions_path.exists():
|
|
295
|
+
return None
|
|
296
|
+
return json.loads(permissions_path.read_text(encoding="utf-8"))
|
|
297
|
+
|
|
298
|
+
|
|
272
299
|
def _archive_file(base: Path, file_name: str) -> Optional[Path]:
|
|
273
300
|
"""Resolve ``base / file_name`` and confirm it stays inside ``base``.
|
|
274
301
|
|
|
@@ -14,7 +14,7 @@ Formats:
|
|
|
14
14
|
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Optional, Union
|
|
17
|
+
from typing import Any, Dict, Optional, Union
|
|
18
18
|
from uuid import UUID
|
|
19
19
|
|
|
20
20
|
from cognee.modules.migration.cogx import (
|
|
@@ -23,6 +23,7 @@ from cognee.modules.migration.cogx import (
|
|
|
23
23
|
COGXEntity,
|
|
24
24
|
COGXFact,
|
|
25
25
|
parse_timestamp,
|
|
26
|
+
write_social_layer,
|
|
26
27
|
)
|
|
27
28
|
from cognee.modules.migration.formats import write_cypher, write_graphml, write_json
|
|
28
29
|
from cognee.modules.migration.snapshot import GraphSnapshot, build_snapshot
|
|
@@ -51,13 +52,98 @@ class ExportResult:
|
|
|
51
52
|
)
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
def _user_payload(user_row) -> Dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"email": user_row.email,
|
|
58
|
+
"hashed_password": user_row.hashed_password,
|
|
59
|
+
"is_active": user_row.is_active,
|
|
60
|
+
"is_superuser": user_row.is_superuser,
|
|
61
|
+
"is_verified": user_row.is_verified,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _gather_social_layer(dataset_obj) -> Dict[str, Any]:
|
|
66
|
+
"""The dataset's owner and ACL grants, including user credentials.
|
|
67
|
+
|
|
68
|
+
Emails, password hashes, and account flags are included so a
|
|
69
|
+
cognee-to-cognee restore can recreate functional accounts — which makes
|
|
70
|
+
an archive carrying this payload a SECRET. Only gathered on explicit
|
|
71
|
+
request (``include_permissions=True``).
|
|
72
|
+
"""
|
|
73
|
+
from sqlalchemy import select
|
|
74
|
+
|
|
75
|
+
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
76
|
+
from cognee.modules.users.models import ACL, Permission, User
|
|
77
|
+
|
|
78
|
+
db_engine = get_relational_engine()
|
|
79
|
+
async with db_engine.get_async_session() as session:
|
|
80
|
+
owner = await session.get(User, dataset_obj.owner_id)
|
|
81
|
+
rows = (
|
|
82
|
+
await session.execute(
|
|
83
|
+
select(User, Permission.name)
|
|
84
|
+
.join(ACL, ACL.principal_id == User.id)
|
|
85
|
+
.join(Permission, Permission.id == ACL.permission_id)
|
|
86
|
+
.where(ACL.dataset_id == dataset_obj.id)
|
|
87
|
+
)
|
|
88
|
+
).all()
|
|
89
|
+
|
|
90
|
+
grants: Dict[str, Dict[str, Any]] = {}
|
|
91
|
+
for user_row, permission_name in rows:
|
|
92
|
+
grant = grants.setdefault(
|
|
93
|
+
user_row.email, {"user": _user_payload(user_row), "permissions": []}
|
|
94
|
+
)
|
|
95
|
+
grant["permissions"].append(permission_name)
|
|
96
|
+
return {
|
|
97
|
+
"owner": _user_payload(owner) if owner else None,
|
|
98
|
+
"grants": sorted(grants.values(), key=lambda g: g["user"]["email"]),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _stored_migration_revision(dataset_id) -> Optional[str]:
|
|
103
|
+
"""The source store's stamped data-migration revision, for the manifest.
|
|
104
|
+
|
|
105
|
+
Per-dataset bookkeeping with backend access control on, the global row
|
|
106
|
+
with it off. None (no row / never migrated / bookkeeping unreadable)
|
|
107
|
+
means "unknown" — the importer then keeps its own stamp.
|
|
108
|
+
"""
|
|
109
|
+
from cognee.context_global_variables import backend_access_control_enabled
|
|
110
|
+
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
db_engine = get_relational_engine()
|
|
114
|
+
if backend_access_control_enabled():
|
|
115
|
+
from cognee.modules.users.models import DatasetDatabase
|
|
116
|
+
|
|
117
|
+
async with db_engine.get_async_session() as session:
|
|
118
|
+
record = await session.get(DatasetDatabase, dataset_id)
|
|
119
|
+
return record.migration_revision if record else None
|
|
120
|
+
|
|
121
|
+
from cognee.modules.migrations.models import (
|
|
122
|
+
GLOBAL_DATABASE_VERSION_ROW_ID,
|
|
123
|
+
GlobalDatabaseVersion,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async with db_engine.get_async_session() as session:
|
|
127
|
+
record = await session.get(GlobalDatabaseVersion, GLOBAL_DATABASE_VERSION_ROW_ID)
|
|
128
|
+
return record.global_migration_revision if record else None
|
|
129
|
+
except Exception as error: # noqa: BLE001 — manifest metadata is best effort
|
|
130
|
+
logger.debug("Could not determine migration revision for manifest: %s", error)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
54
134
|
def _write_cogx(
|
|
55
|
-
nodes,
|
|
135
|
+
nodes,
|
|
136
|
+
edges,
|
|
137
|
+
destination: Path,
|
|
138
|
+
dataset_name: str,
|
|
139
|
+
embedding_model: Optional[str] = None,
|
|
140
|
+
migration_revision: Optional[str] = None,
|
|
56
141
|
) -> None:
|
|
57
142
|
"""Map graph nodes/edges onto typed COGX records; keep the rest raw."""
|
|
58
143
|
with COGXArchiveWriter(destination, source_system="cognee") as writer:
|
|
59
144
|
writer.add_note(f"Exported from Cognee dataset {dataset_name!r}.")
|
|
60
145
|
writer.embedding_model = embedding_model
|
|
146
|
+
writer.migration_revision = migration_revision
|
|
61
147
|
for node_id, properties in nodes:
|
|
62
148
|
properties = properties or {}
|
|
63
149
|
node_type = properties.get("type")
|
|
@@ -112,9 +198,16 @@ async def export_dataset(
|
|
|
112
198
|
destination: Optional[Union[str, Path]] = None,
|
|
113
199
|
user=None,
|
|
114
200
|
link_relations: bool = False,
|
|
201
|
+
include_permissions: bool = False,
|
|
115
202
|
) -> Union[ExportResult, GraphSnapshot]:
|
|
116
203
|
"""Export an authorized dataset's graph. Requires read permission.
|
|
117
204
|
|
|
205
|
+
``include_permissions`` (cogx only): additionally write the dataset's
|
|
206
|
+
social layer — owner and ACL grants WITH user credentials — into
|
|
207
|
+
``permissions.json`` so a cognee-to-cognee restore recreates functional
|
|
208
|
+
accounts and grants. The archive then contains password hashes: treat it
|
|
209
|
+
as a secret.
|
|
210
|
+
|
|
118
211
|
``format="pydantic"`` returns a :class:`GraphSnapshot` (typed DataPoint
|
|
119
212
|
instances, in memory; pass ``destination`` to also persist it as JSON).
|
|
120
213
|
All other formats write a file and return an :class:`ExportResult`.
|
|
@@ -179,7 +272,16 @@ async def export_dataset(
|
|
|
179
272
|
embedding_model = get_embedding_context_config().embedding_model
|
|
180
273
|
except Exception as error: # noqa: BLE001 — manifest metadata is best effort
|
|
181
274
|
logger.debug("Could not determine embedding model for manifest: %s", error)
|
|
182
|
-
_write_cogx(
|
|
275
|
+
_write_cogx(
|
|
276
|
+
nodes,
|
|
277
|
+
edges,
|
|
278
|
+
destination,
|
|
279
|
+
dataset_obj.name,
|
|
280
|
+
embedding_model=embedding_model,
|
|
281
|
+
migration_revision=await _stored_migration_revision(dataset_obj.id),
|
|
282
|
+
)
|
|
283
|
+
if include_permissions:
|
|
284
|
+
write_social_layer(destination, await _gather_social_layer(dataset_obj))
|
|
183
285
|
elif format == "json":
|
|
184
286
|
write_json(nodes, edges, destination)
|
|
185
287
|
elif format == "graphml":
|
|
@@ -42,6 +42,210 @@ DATA_ITEMS_PER_ADD = 200
|
|
|
42
42
|
_GRAPH_RECORD_KINDS = ("entity", "fact", "raw_node")
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
async def _ensure_user(user_payload: Dict[str, Any]):
|
|
46
|
+
"""Create-or-match a user by email, transferring credentials on creation.
|
|
47
|
+
|
|
48
|
+
An existing target user is returned untouched — their credentials are
|
|
49
|
+
never clobbered by an import. A missing user is created and then given
|
|
50
|
+
the archived credentials (hashed password + account flags) directly, so
|
|
51
|
+
restored accounts authenticate with their original passwords.
|
|
52
|
+
"""
|
|
53
|
+
import secrets
|
|
54
|
+
|
|
55
|
+
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
56
|
+
from cognee.modules.users.methods import create_user, get_user_by_email
|
|
57
|
+
from cognee.modules.users.models import User
|
|
58
|
+
|
|
59
|
+
existing = await get_user_by_email(user_payload["email"])
|
|
60
|
+
if existing is not None:
|
|
61
|
+
return existing
|
|
62
|
+
|
|
63
|
+
created = await create_user(user_payload["email"], secrets.token_urlsafe(32))
|
|
64
|
+
db_engine = get_relational_engine()
|
|
65
|
+
async with db_engine.get_async_session() as session:
|
|
66
|
+
record = await session.get(User, created.id)
|
|
67
|
+
record.hashed_password = user_payload["hashed_password"]
|
|
68
|
+
record.is_active = user_payload.get("is_active", True)
|
|
69
|
+
record.is_superuser = user_payload.get("is_superuser", False)
|
|
70
|
+
record.is_verified = user_payload.get("is_verified", False)
|
|
71
|
+
await session.commit()
|
|
72
|
+
logger.info("Restored user %s from archive social layer.", user_payload["email"])
|
|
73
|
+
return created
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _resolve_import_user(source: MemorySource, user):
|
|
77
|
+
"""The identity the import runs as.
|
|
78
|
+
|
|
79
|
+
Archives carrying a social layer import AS the archived dataset OWNER
|
|
80
|
+
(created/matched by email first): per-dataset databases derive their
|
|
81
|
+
physical location from the owner id, so ownership must be right BEFORE
|
|
82
|
+
the rows land — it cannot be reassigned afterwards. All other imports
|
|
83
|
+
run as the caller's user, exactly as before.
|
|
84
|
+
|
|
85
|
+
Processing a social layer requires a SUPERUSER importer: the archive
|
|
86
|
+
supplies emails, password hashes, and account flags verbatim, so an
|
|
87
|
+
unprivileged importer could otherwise mint arbitrary accounts (including
|
|
88
|
+
superusers) with credentials of their choosing — both via the SDK and via
|
|
89
|
+
the /v1/remember archive-upload endpoint.
|
|
90
|
+
"""
|
|
91
|
+
social_layer = getattr(source, "social_layer", None)
|
|
92
|
+
owner_payload = (social_layer or {}).get("owner")
|
|
93
|
+
if owner_payload is None:
|
|
94
|
+
return user
|
|
95
|
+
|
|
96
|
+
importer = user
|
|
97
|
+
if importer is None:
|
|
98
|
+
from cognee.modules.users.methods import get_default_user
|
|
99
|
+
|
|
100
|
+
importer = await get_default_user()
|
|
101
|
+
if not importer.is_superuser:
|
|
102
|
+
from cognee.modules.users.exceptions.exceptions import PermissionDeniedError
|
|
103
|
+
|
|
104
|
+
raise PermissionDeniedError(
|
|
105
|
+
message="Importing an archive that carries a social layer (permissions.json) "
|
|
106
|
+
"requires a superuser: it restores user accounts and credentials."
|
|
107
|
+
)
|
|
108
|
+
return await _ensure_user(owner_payload)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _apply_social_grants(source: MemorySource, dataset_name: str, owner, importer) -> None:
|
|
112
|
+
"""Re-apply the archive's ACL grants onto the freshly imported dataset.
|
|
113
|
+
|
|
114
|
+
Users are created/matched by email (credentials transfer on creation);
|
|
115
|
+
``give_permission_on_dataset`` deduplicates existing ACL rows, so
|
|
116
|
+
re-importing is idempotent. The importing user additionally keeps read
|
|
117
|
+
access — they held the archive bytes, and this prevents silent lockout
|
|
118
|
+
when restoring someone else's dataset.
|
|
119
|
+
"""
|
|
120
|
+
social_layer = getattr(source, "social_layer", None)
|
|
121
|
+
if not social_layer:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
from cognee.modules.data.methods import get_authorized_existing_datasets
|
|
125
|
+
from cognee.modules.users.permissions.methods import give_permission_on_dataset
|
|
126
|
+
|
|
127
|
+
datasets = await get_authorized_existing_datasets([dataset_name], "read", owner)
|
|
128
|
+
if not datasets:
|
|
129
|
+
logger.warning(
|
|
130
|
+
"No dataset %r found after import; cannot restore its social layer.", dataset_name
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
dataset_id = datasets[0].id
|
|
134
|
+
|
|
135
|
+
for grant in social_layer.get("grants", []):
|
|
136
|
+
principal = await _ensure_user(grant["user"])
|
|
137
|
+
for permission_name in grant.get("permissions", []):
|
|
138
|
+
await give_permission_on_dataset(principal, dataset_id, permission_name)
|
|
139
|
+
|
|
140
|
+
if importer is None:
|
|
141
|
+
from cognee.modules.users.methods import get_default_user
|
|
142
|
+
|
|
143
|
+
importer = await get_default_user()
|
|
144
|
+
if importer.id != owner.id:
|
|
145
|
+
await give_permission_on_dataset(importer, dataset_id, "read")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _revision_to_stamp(
|
|
149
|
+
archive_revision: Optional[str],
|
|
150
|
+
stored_revision: Optional[str],
|
|
151
|
+
ordered_revisions: List[str],
|
|
152
|
+
) -> Optional[str]:
|
|
153
|
+
"""The revision the imported store should be re-stamped at, or None.
|
|
154
|
+
|
|
155
|
+
Stamps only BACKWARD — when the archive's revision is strictly behind the
|
|
156
|
+
store's current stamp — so the next migration run replays exactly
|
|
157
|
+
archive -> head over the imported rows (idempotent over already-current
|
|
158
|
+
data). Never forward: stamping ahead would mark the store's own data as
|
|
159
|
+
migrated when it is not. Unknown revisions (either side written by newer
|
|
160
|
+
code) and an unstamped store (None = base, already minimal) leave the
|
|
161
|
+
stamp untouched.
|
|
162
|
+
"""
|
|
163
|
+
if archive_revision is None or stored_revision is None:
|
|
164
|
+
return None
|
|
165
|
+
if archive_revision not in ordered_revisions or stored_revision not in ordered_revisions:
|
|
166
|
+
return None
|
|
167
|
+
if ordered_revisions.index(archive_revision) < ordered_revisions.index(stored_revision):
|
|
168
|
+
return archive_revision
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _restamp_to_source_revision(source: MemorySource, dataset_name: str, user) -> None:
|
|
173
|
+
"""Align the target's migration stamp with a cognee-origin archive.
|
|
174
|
+
|
|
175
|
+
Preserve/hybrid imports write the archive's raw nodes with their
|
|
176
|
+
source-store ids, so the target must not claim a newer data-migration
|
|
177
|
+
revision than the exported data actually has. When the archive carries a
|
|
178
|
+
revision behind the target's stamp, re-stamp backward; the next migration
|
|
179
|
+
gate then replays revision -> head over the imported data. External
|
|
180
|
+
sources carry no revision (their records are written entirely by
|
|
181
|
+
current-code pipelines) and are skipped, as are re-derive imports (no raw
|
|
182
|
+
nodes land).
|
|
183
|
+
"""
|
|
184
|
+
archive_revision = getattr(source, "migration_revision", None)
|
|
185
|
+
if archive_revision is None or source.mode == "re-derive":
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
from cognee.context_global_variables import backend_access_control_enabled
|
|
189
|
+
from cognee.infrastructure.databases.relational import get_relational_engine
|
|
190
|
+
from cognee.modules.migrations.migration import order_migrations
|
|
191
|
+
from cognee.modules.migrations.registry import MIGRATIONS
|
|
192
|
+
from cognee.modules.migrations.runner import stamp_revisions
|
|
193
|
+
|
|
194
|
+
ordered_revisions = [migration.revision for migration in order_migrations(MIGRATIONS)]
|
|
195
|
+
if archive_revision not in ordered_revisions:
|
|
196
|
+
logger.warning(
|
|
197
|
+
"Archive migration revision %r is unknown to this chain — the archive was "
|
|
198
|
+
"exported by newer code; leaving the store's migration stamp unchanged.",
|
|
199
|
+
archive_revision,
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
db_engine = get_relational_engine()
|
|
204
|
+
if backend_access_control_enabled():
|
|
205
|
+
from cognee.modules.data.methods import get_authorized_existing_datasets
|
|
206
|
+
from cognee.modules.users.methods import get_default_user
|
|
207
|
+
from cognee.modules.users.models import DatasetDatabase
|
|
208
|
+
|
|
209
|
+
if user is None:
|
|
210
|
+
user = await get_default_user()
|
|
211
|
+
datasets = await get_authorized_existing_datasets([dataset_name], "read", user)
|
|
212
|
+
if not datasets:
|
|
213
|
+
logger.warning(
|
|
214
|
+
"No dataset %r found after import; cannot align its migration stamp.",
|
|
215
|
+
dataset_name,
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
dataset_id = datasets[0].id
|
|
219
|
+
async with db_engine.get_async_session() as session:
|
|
220
|
+
record = await session.get(DatasetDatabase, dataset_id)
|
|
221
|
+
stored_revision = record.migration_revision if record else None
|
|
222
|
+
target = _revision_to_stamp(archive_revision, stored_revision, ordered_revisions)
|
|
223
|
+
if target is None:
|
|
224
|
+
return
|
|
225
|
+
await stamp_revisions(target=target, dataset_ids=[dataset_id])
|
|
226
|
+
else:
|
|
227
|
+
from cognee.modules.migrations.models import (
|
|
228
|
+
GLOBAL_DATABASE_VERSION_ROW_ID,
|
|
229
|
+
GlobalDatabaseVersion,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async with db_engine.get_async_session() as session:
|
|
233
|
+
record = await session.get(GlobalDatabaseVersion, GLOBAL_DATABASE_VERSION_ROW_ID)
|
|
234
|
+
stored_revision = record.global_migration_revision if record else None
|
|
235
|
+
target = _revision_to_stamp(archive_revision, stored_revision, ordered_revisions)
|
|
236
|
+
if target is None:
|
|
237
|
+
return
|
|
238
|
+
await stamp_revisions(target=target)
|
|
239
|
+
|
|
240
|
+
logger.info(
|
|
241
|
+
"Stamped store back to archive migration revision %r (was %r); the next "
|
|
242
|
+
"migration run replays %r -> head over the imported data.",
|
|
243
|
+
target,
|
|
244
|
+
stored_revision,
|
|
245
|
+
target,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
45
249
|
def _pipeline_run_id(pipeline_result: Any) -> Optional[str]:
|
|
46
250
|
"""Extract the pipeline run id from a run_custom_pipeline return value.
|
|
47
251
|
|
|
@@ -84,14 +288,34 @@ async def import_memory_source(
|
|
|
84
288
|
# before its own gate). This also records the data-migration revision —
|
|
85
289
|
# stamping a fresh store at head — BEFORE the imported rows arrive;
|
|
86
290
|
# without it the populated store has no recorded revision and the first
|
|
87
|
-
# migration-aware startup replays the entire data chain over it.
|
|
291
|
+
# migration-aware startup replays the entire data chain over it. It must
|
|
292
|
+
# run before user resolution: on a fresh store it also creates the
|
|
293
|
+
# relational schema that user lookup needs.
|
|
88
294
|
await run_migrations_and_block(dataset_name, user)
|
|
89
295
|
|
|
296
|
+
# Archives carrying a social layer import AS the archived owner (see
|
|
297
|
+
# _resolve_import_user); everything else runs as the caller's user.
|
|
298
|
+
importer = user
|
|
299
|
+
user = await _resolve_import_user(source, user)
|
|
300
|
+
|
|
90
301
|
node_set = node_set or [f"import:{source.source_system}"]
|
|
91
302
|
|
|
92
303
|
if source.mode == "preserve" and getattr(source, "replayable", False):
|
|
93
|
-
|
|
94
|
-
|
|
304
|
+
result = await _import_streaming(source, dataset_name, user, run_in_background, node_set)
|
|
305
|
+
else:
|
|
306
|
+
result = await _import_buffered(
|
|
307
|
+
source, dataset_name, user, run_in_background, node_set, **kwargs
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# After the rows land: cognee-origin archives may need the migration
|
|
311
|
+
# stamp aligned backward to the SOURCE store's revision (see
|
|
312
|
+
# _restamp_to_source_revision) and their social layer restored (grants
|
|
313
|
+
# re-applied for recreated users).
|
|
314
|
+
await _restamp_to_source_revision(source, dataset_name, user)
|
|
315
|
+
if getattr(source, "social_layer", None):
|
|
316
|
+
await _apply_social_grants(source, dataset_name, owner=user, importer=importer)
|
|
317
|
+
|
|
318
|
+
return result
|
|
95
319
|
|
|
96
320
|
|
|
97
321
|
async def _import_streaming(
|
|
@@ -130,7 +354,12 @@ async def _import_streaming(
|
|
|
130
354
|
|
|
131
355
|
logger.info("Importing from %s (mode=preserve, streaming): %s", source.source_system, counts)
|
|
132
356
|
|
|
133
|
-
stats: Dict[str, int] = {
|
|
357
|
+
stats: Dict[str, int] = {
|
|
358
|
+
"graph_nodes": 0,
|
|
359
|
+
"graph_edges": 0,
|
|
360
|
+
"skipped_facts": 0,
|
|
361
|
+
"deduped_edges": 0,
|
|
362
|
+
}
|
|
134
363
|
pipeline_result = None
|
|
135
364
|
has_graph_records = any(counts.get(kind) for kind in _GRAPH_RECORD_KINDS)
|
|
136
365
|
if has_graph_records:
|
|
@@ -175,6 +404,7 @@ async def _import_streaming(
|
|
|
175
404
|
"graph_nodes": stats["graph_nodes"],
|
|
176
405
|
"graph_edges": stats["graph_edges"],
|
|
177
406
|
"skipped_facts": stats["skipped_facts"],
|
|
407
|
+
"deduped_edges": stats["deduped_edges"],
|
|
178
408
|
"pipeline_run_id": run_id,
|
|
179
409
|
}
|
|
180
410
|
if backgrounded:
|
|
@@ -208,7 +438,13 @@ async def _import_buffered(
|
|
|
208
438
|
|
|
209
439
|
# Translate the record stream directly: no full raw-record list is kept,
|
|
210
440
|
# so peak memory is bounded by the translation output alone.
|
|
211
|
-
translation = await translate_record_stream(
|
|
441
|
+
translation = await translate_record_stream(
|
|
442
|
+
source.records(),
|
|
443
|
+
source.mode,
|
|
444
|
+
# Cognee-origin archives keep source node UUIDs verbatim; other
|
|
445
|
+
# systems get class-namespaced ids (see _register_entity).
|
|
446
|
+
preserve_source_ids=source.source_system == "cognee",
|
|
447
|
+
)
|
|
212
448
|
|
|
213
449
|
logger.info(
|
|
214
450
|
"Importing %d records from %s (mode=%s): %s",
|