fix: preserve reverse migration record hashes
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

This commit is contained in:
tegwick 2026-08-21 18:03:52 +02:00
parent b649ea7168
commit 9adeb013cd
2 changed files with 30 additions and 2 deletions

View file

@ -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:

View file

@ -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):