diff --git a/hub_core/runtime/migration.py b/hub_core/runtime/migration.py index 6dd3cfa..40d6a48 100644 --- a/hub_core/runtime/migration.py +++ b/hub_core/runtime/migration.py @@ -413,7 +413,7 @@ async def export_bundle(store: PostgresPortStore, *, source_revision: str | None "apiConsumers": [_body(row, created=True) for row in consumers], "apiKeys": [{"id": row["id"], "apiConsumerId": row["api_consumer_id"], "keyPrefix": row["key_prefix"], "keyHash": row["key_hash"], "scopes": row["scopes"], "status": row["status"], "createdAt": _timestamp(row["created_at"])} for row in keys], "widgets": [_body(row, created=True) for row in widgets], - "interactionEvents": [{**dict((row["payload"] or {}).get("legacy") or {}), "id": row["id"], "widgetId": (row["subject_refs"] or {}).get("widget"), "eventType": row["event_type"], "occurredAt": _timestamp(row["occurred_at"]), "recordedAt": _timestamp(row["recorded_at"]), "correlationId": row["correlation_id"]} for row in events], + "interactionEvents": [_export_interaction(row) for row in events], "migrationRuns": [{"id": row["id"], "source": row["source"], "schemaVersion": row["schema_version"], "bundleSha256": row["bundle_sha256"], "dryRun": row["dry_run"], "status": row["status"], "counts": row["counts"], "diagnostics": row["diagnostics"], "createdAt": _timestamp(row["created_at"])} for row in runs], } high_water = max((_timestamp(row["recorded_at"]) for row in events), default=None) @@ -424,8 +424,32 @@ async def export_bundle(store: PostgresPortStore, *, source_revision: str | None return bundle +def _export_interaction(row: Any) -> dict[str, Any]: + legacy = dict((row["payload"] or {}).get("legacy") or {}) + if legacy: + # Imported records retain their exact source representation so reverse + # export is a lossless rollback artifact, including optional timestamp + # and correlation fields that were absent in the source. + return legacy + return { + "id": row["id"], + "widgetId": (row["subject_refs"] or {}).get("widget"), + "eventType": row["event_type"], + "occurredAt": _timestamp(row["occurred_at"]), + "recordedAt": _timestamp(row["recorded_at"]), + "correlationId": row["correlation_id"], + "metadata": row["payload"], + } + + def _body(row: Any, *, created: bool = False, updated: bool = False) -> dict[str, Any]: value = dict(row["body"] or {}) + if value.get("id") == row["id"]: + # Import stores the complete source record as its body. Returning that + # record verbatim keeps reverse export hash- and field-exact; records + # created natively lack the generated database identity and are enriched + # from their columns below. + return value if created: value["createdAt"] = _timestamp(row["created_at"]) if updated: diff --git a/tests/test_migration.py b/tests/test_migration.py index f29dc61..1de1ff5 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -24,7 +24,7 @@ def bundle() -> dict: "hubs": [{"id": "hub-1", "slug": "ops-hub", "name": "Ops Hub", "createdAt": "2026-08-20T10:00:00Z", "updatedAt": "2026-08-20T11:00:00Z"}], "hubCapabilityManifests": [{"id": "manifest-1", "hubId": "hub-1", "manifestVersion": "1.0", "status": "active"}], "apiConsumers": [{"id": "consumer-1", "slug": "ops-hub", "name": "ops-hub", "hubCapabilityManifestId": "manifest-1"}], - "apiKeys": [{"id": "key-1", "apiConsumerId": "consumer-1", "keyPrefix": "ch_prefix", "keyHash": "a" * 64, "scopes": "framework:read"}], + "apiKeys": [{"id": "key-1", "apiConsumerId": "consumer-1", "keyPrefix": "ch_prefix", "keyHash": "a" * 64, "scopes": "framework:read", "status": "active", "createdAt": "2026-08-21T10:00:00Z"}], "widgets": [{"id": "widget-1", "hubId": "hub-1", "name": "Readiness", "widgetType": "ops-readiness-gate"}], "interactionEvents": [{"id": "event-1", "widgetId": "widget-1", "eventType": "ops-endpoint-verified", "metadata": {"status": 401}, "createdAt": "2026-08-21T11:00:00Z"}], "migrationRuns": [{"id": "run-1", "source": "inter-hub", "schemaVersion": "core-hub.migration.v1", "bundleSha256": "b" * 64, "dryRun": False, "status": "imported", "counts": {}, "diagnostics": {}, "createdAt": "2026-08-19T09:00:00Z"}], @@ -93,6 +93,10 @@ def test_import_is_idempotent_and_exports_all_seven_collections(tmp_path): assert exported["records"]["apiKeys"][0]["keyHash"] == "a" * 64 assert "fullKey" not in json.dumps(exported) assert validate_bundle(exported)["ok"] is True + assert ( + validate_bundle(exported)["contentHashes"] + == validate_bundle(bundle())["contentHashes"] + ) def test_dry_run_does_not_record_import(tmp_path):