feat: add migration export and write fencing
This commit is contained in:
parent
9285a880be
commit
f6758b91ce
10 changed files with 317 additions and 25 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import hashlib
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -34,10 +35,179 @@ def encode_body(payload: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
def bundle_digest(bundle: dict[str, Any]) -> str:
|
||||
canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":"))
|
||||
digestable = {key: value for key, value in bundle.items() if key != "bundleSha256"}
|
||||
canonical = json.dumps(digestable, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _decoded(raw: str | None) -> dict[str, Any]:
|
||||
value = json.loads(raw or "{}")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _with_timestamps(
|
||||
body: str | None,
|
||||
*,
|
||||
created_at: datetime,
|
||||
updated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
value = _decoded(body)
|
||||
value["createdAt"] = _timestamp(created_at)
|
||||
if updated_at is not None:
|
||||
value["updatedAt"] = _timestamp(updated_at)
|
||||
return value
|
||||
|
||||
|
||||
async def export_bundle(
|
||||
session: AsyncSession, *, source_revision: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Export all seven durable Core Hub tables without raw credential material."""
|
||||
hubs = list((await session.execute(select(Hub).order_by(Hub.id))).scalars())
|
||||
manifests = list(
|
||||
(await session.execute(select(HubCapabilityManifest).order_by(HubCapabilityManifest.id)))
|
||||
.scalars()
|
||||
)
|
||||
consumers = list(
|
||||
(await session.execute(select(ApiConsumer).order_by(ApiConsumer.id))).scalars()
|
||||
)
|
||||
keys = list((await session.execute(select(ApiKey).order_by(ApiKey.id))).scalars())
|
||||
widgets = list((await session.execute(select(Widget).order_by(Widget.id))).scalars())
|
||||
events = list(
|
||||
(await session.execute(select(InteractionEvent).order_by(InteractionEvent.id))).scalars()
|
||||
)
|
||||
runs = list(
|
||||
(await session.execute(select(MigrationRun).order_by(MigrationRun.id))).scalars()
|
||||
)
|
||||
|
||||
records: dict[str, list[dict[str, Any]]] = {
|
||||
"hubs": [],
|
||||
"hubCapabilityManifests": [],
|
||||
"apiConsumers": [],
|
||||
"apiKeys": [],
|
||||
"widgets": [],
|
||||
"interactionEvents": [],
|
||||
"migrationRuns": [],
|
||||
}
|
||||
for row in hubs:
|
||||
value = _with_timestamps(
|
||||
row.body_json, created_at=row.created_at, updated_at=row.updated_at
|
||||
)
|
||||
value.update(
|
||||
id=row.id,
|
||||
slug=row.slug,
|
||||
name=row.name,
|
||||
domain=row.domain,
|
||||
hubKind=row.hub_kind,
|
||||
hubFamily=row.hub_family,
|
||||
vsmFunction=row.vsm_function,
|
||||
vsmSystem=row.vsm_system,
|
||||
status=row.status,
|
||||
description=row.description,
|
||||
)
|
||||
records["hubs"].append({key: child for key, child in value.items() if child is not None})
|
||||
for row in manifests:
|
||||
value = _with_timestamps(
|
||||
row.body_json, created_at=row.created_at, updated_at=row.updated_at
|
||||
)
|
||||
value.update(
|
||||
id=row.id,
|
||||
hubId=row.hub_id,
|
||||
hubSlug=row.hub_slug,
|
||||
manifestVersion=row.manifest_version,
|
||||
status=row.status,
|
||||
)
|
||||
records["hubCapabilityManifests"].append(
|
||||
{key: child for key, child in value.items() if child is not None}
|
||||
)
|
||||
for row in consumers:
|
||||
value = _with_timestamps(row.body_json, created_at=row.created_at)
|
||||
value.update(
|
||||
id=row.id,
|
||||
slug=row.slug,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
hubCapabilityManifestId=row.hub_capability_manifest_id,
|
||||
rateLimitPerMinute=row.rate_limit_per_minute,
|
||||
quotaPerDay=row.quota_per_day,
|
||||
keyPrefix=row.key_prefix,
|
||||
status=row.status,
|
||||
)
|
||||
records["apiConsumers"].append(
|
||||
{key: child for key, child in value.items() if child is not None}
|
||||
)
|
||||
records["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
|
||||
]
|
||||
for row in widgets:
|
||||
value = _with_timestamps(row.body_json, created_at=row.created_at)
|
||||
value.update(
|
||||
id=row.id,
|
||||
hubId=row.hub_id,
|
||||
name=row.name,
|
||||
widgetType=row.widget_type,
|
||||
capabilityRef=row.capability_ref,
|
||||
viewContext=row.view_context,
|
||||
policyScope=row.policy_scope,
|
||||
status=row.status,
|
||||
)
|
||||
records["widgets"].append(
|
||||
{key: child for key, child in value.items() if child is not None}
|
||||
)
|
||||
for row in events:
|
||||
value = _with_timestamps(row.body_json, created_at=row.created_at)
|
||||
value.update(
|
||||
id=row.id,
|
||||
widgetId=row.widget_id,
|
||||
eventType=row.event_type,
|
||||
viewContext=row.view_context,
|
||||
metadata=_decoded(row.metadata_json),
|
||||
)
|
||||
records["interactionEvents"].append(
|
||||
{key: child for key, child in value.items() if child is not None}
|
||||
)
|
||||
records["migrationRuns"] = [
|
||||
{
|
||||
"id": row.id,
|
||||
"source": row.source,
|
||||
"schemaVersion": row.schema_version,
|
||||
"bundleSha256": row.bundle_sha256,
|
||||
"dryRun": row.dry_run,
|
||||
"status": row.status,
|
||||
"counts": _decoded(row.counts_json),
|
||||
"diagnostics": _decoded(row.diagnostics_json),
|
||||
"createdAt": _timestamp(row.created_at),
|
||||
}
|
||||
for row in runs
|
||||
]
|
||||
high_water = max((_timestamp(row.created_at) for row in events), default=None)
|
||||
bundle: dict[str, Any] = {
|
||||
"schemaVersion": MIGRATION_SCHEMA_VERSION,
|
||||
"source": "core-hub",
|
||||
"exportedAt": _timestamp(datetime.now(timezone.utc)),
|
||||
"highWaterMark": high_water,
|
||||
"records": records,
|
||||
}
|
||||
if source_revision:
|
||||
bundle["sourceRevision"] = source_revision
|
||||
bundle["bundleSha256"] = bundle_digest(bundle)
|
||||
return bundle
|
||||
|
||||
|
||||
def records_for(bundle: dict[str, Any], collection: str) -> list[dict[str, Any]]:
|
||||
records = bundle.get("records", {})
|
||||
if not isinstance(records, dict):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue