From 652922f3a06def0dea43a03b19d3646b94a24090 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 17:22:00 -0500 Subject: [PATCH 1/6] Deploy MapLibre rendering and verified R2 visual asset storage --- .dockerignore | 1 + .gitignore | 1 + .../api/v1/endpoint_modules/static_maps.py | 135 +- .../app/api/v1/endpoint_modules/thumbnails.py | 10 + backend/app/services/image_service.py | 27 +- backend/app/services/native_static_maps.py | 175 + backend/app/services/static_map_service.py | 142 +- backend/app/services/visual_asset_cache.py | 129 +- .../app/services/visual_asset_object_store.py | 107 + backend/app/tasks/static_maps.py | 66 + .../add_visual_asset_object_storage.py | 29 + backend/db/models.py | 4 +- backend/pyproject.toml | 1 + .../scripts/backfill_visual_assets_to_s3.py | 103 + backend/scripts/prime_static_map_cache.py | 4 + backend/scripts/run_migrations.py | 2 + .../tests/api/v1/test_static_map_endpoints.py | 1 + .../scripts/test_prime_static_map_cache.py | 22 + .../tests/services/test_native_static_maps.py | 146 + .../tests/services/test_static_map_service.py | 4 +- .../services/test_visual_asset_backfill.py | 85 + .../test_visual_asset_object_store.py | 196 ++ .../maplibre-static-prototype/.gitignore | 2 + .../Dockerfile.linux | 15 + .../Dockerfile.worker | 16 + .../LINUX-RESULTS.md | 96 + .../tools/maplibre-static-prototype/README.md | 70 + .../maplibre-static-prototype/campus.json | 12 + .../maplibre-static-prototype/check_celery.py | 31 + .../maplibre-static-prototype/check_http.py | 29 + .../integrated_app.py | 42 + .../package-lock.json | 3058 +++++++++++++++++ .../maplibre-static-prototype/package.json | 14 + .../maplibre-static-prototype/preview.html | 6 + .../maplibre-static-prototype/prime-assets.py | 36 + .../maplibre-static-prototype/render.mjs | 198 ++ .../maplibre-static-prototype/render.test.mjs | 13 + .../maplibre-static-prototype/resource.json | 42 + .../maplibre-static-prototype/server.mjs | 122 + .../verify_assets.py | 47 + backend/uv.lock | 51 + config/deploy.yml | 27 +- deploy/Dockerfile.visual-assets | 9 + docker-compose.native-maps.yml | 29 + docs/native-static-maps.md | 180 + docs/r2-visual-assets.md | 64 + 46 files changed, 5537 insertions(+), 62 deletions(-) create mode 100644 backend/app/services/native_static_maps.py create mode 100644 backend/app/services/visual_asset_object_store.py create mode 100644 backend/db/migrations/add_visual_asset_object_storage.py create mode 100644 backend/scripts/backfill_visual_assets_to_s3.py create mode 100644 backend/tests/services/test_native_static_maps.py create mode 100644 backend/tests/services/test_visual_asset_backfill.py create mode 100644 backend/tests/services/test_visual_asset_object_store.py create mode 100644 backend/tools/maplibre-static-prototype/.gitignore create mode 100644 backend/tools/maplibre-static-prototype/Dockerfile.linux create mode 100644 backend/tools/maplibre-static-prototype/Dockerfile.worker create mode 100644 backend/tools/maplibre-static-prototype/LINUX-RESULTS.md create mode 100644 backend/tools/maplibre-static-prototype/README.md create mode 100644 backend/tools/maplibre-static-prototype/campus.json create mode 100644 backend/tools/maplibre-static-prototype/check_celery.py create mode 100644 backend/tools/maplibre-static-prototype/check_http.py create mode 100644 backend/tools/maplibre-static-prototype/integrated_app.py create mode 100644 backend/tools/maplibre-static-prototype/package-lock.json create mode 100644 backend/tools/maplibre-static-prototype/package.json create mode 100644 backend/tools/maplibre-static-prototype/preview.html create mode 100644 backend/tools/maplibre-static-prototype/prime-assets.py create mode 100644 backend/tools/maplibre-static-prototype/render.mjs create mode 100644 backend/tools/maplibre-static-prototype/render.test.mjs create mode 100644 backend/tools/maplibre-static-prototype/resource.json create mode 100644 backend/tools/maplibre-static-prototype/server.mjs create mode 100644 backend/tools/maplibre-static-prototype/verify_assets.py create mode 100644 deploy/Dockerfile.visual-assets create mode 100644 docker-compose.native-maps.yml create mode 100644 docs/native-static-maps.md create mode 100644 docs/r2-visual-assets.md diff --git a/.dockerignore b/.dockerignore index 1f1212f..07aa8cc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +.env.r2.local .git .gitignore .env diff --git a/.gitignore b/.gitignore index f44a537..1aad240 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env.r2.local # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/backend/app/api/v1/endpoint_modules/static_maps.py b/backend/app/api/v1/endpoint_modules/static_maps.py index ca5e6da..a44820c 100644 --- a/backend/app/api/v1/endpoint_modules/static_maps.py +++ b/backend/app/api/v1/endpoint_modules/static_maps.py @@ -1,8 +1,10 @@ +import asyncio import io import logging import os +from typing import Literal -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import Response from PIL import Image from sqlalchemy.sql import select @@ -15,6 +17,7 @@ weak_etag_from_body, ) from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_visual_asset_public_url from db.models import resources logger = logging.getLogger(__name__) @@ -203,15 +206,29 @@ async def _get_or_generate_map_data( raise HTTPException(status_code=400, detail="Unsupported static map variant") +@router.get("/static-maps/institutions/{map_id}/native", response_class=Response) @router.get("/static-maps/institutions/{map_id}", response_class=Response) async def get_institution_static_map( map_id: str, request: Request, - lat: float, - lon: float, - zoom: int = 15, + lat: float = Query(..., ge=-85, le=85), + lon: float = Query(..., ge=-180, le=180), + zoom: int = Query(15, ge=1, le=20), + renderer: str | None = None, ): """Serve or generate a cached campus-centered static basemap image.""" + from app.services.native_static_maps import renderer_url + + if request.url.path.endswith("/native"): + renderer = "maplibre" + if renderer not in (None, "maplibre"): + raise HTTPException(status_code=422, detail="Unknown renderer") + if renderer == "maplibre" and not renderer_url(): + raise HTTPException( + status_code=503, + detail="Native renderer is not configured", + headers={"Cache-Control": "no-store", "Retry-After": "30"}, + ) cache_id = f"institution:{map_id}" try: map_service = StaticMapService(map_width=640, map_height=320) @@ -220,10 +237,50 @@ async def get_institution_static_map( longitude=lon, zoom=zoom, ) + digest = await map_service.get_asset_hash( + cache_id, + variant=map_service.basemap_variant(), + source_signature=source_signature, + hydrate_asset=False, + ) + if digest: + url = await asyncio.to_thread(get_visual_asset_public_url, digest) + if url: + return Response( + status_code=302, + headers={"Location": url, "Cache-Control": "public, max-age=300"}, + ) + durable_data = await map_service.get_cached_asset(digest) + if durable_data: + return _validated_static_asset_response( + durable_data, + request, + cache_control="public, max-age=3600, stale-while-revalidate=86400", + ) map_data = await map_service.get_cached_basemap( cache_id, source_signature=source_signature, ) + if not map_data and renderer_url(): + from app.tasks.worker import celery_app + + lock_key = f"native-campus-pending:{map_id}:{source_signature}" + acquired = await asyncio.to_thread( + map_service.map_cache.set, lock_key, "pending", nx=True, ex=300 + ) + if acquired: + try: + await asyncio.to_thread( + celery_app.send_task, + "generate_campus_static_map", + args=[map_id, lat, lon, zoom, lock_key], + ) + except Exception: + await asyncio.to_thread(map_service.map_cache.delete, lock_key) + raise + return Response( + status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"} + ) if not map_data: map_data = map_service.generate_centered_basemap( cache_id, @@ -239,12 +296,19 @@ async def get_institution_static_map( if not map_data: raise HTTPException(status_code=404, detail="Institution static map not found") - return _validated_static_asset_response(map_data, request) + return _validated_static_asset_response( + map_data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) @router.get("/static-map-assets/{map_hash}", response_class=Response) async def get_static_map_asset(map_hash: str, request: Request): - """Serve a hash-addressed immutable static map or icon asset directly from Redis.""" + """Serve a durable static map, preferring its verified CDN object.""" + url = await asyncio.to_thread(get_visual_asset_public_url, map_hash) + if url: + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) map_service = StaticMapService() map_data = await map_service.get_cached_asset(map_hash) if not map_data: @@ -401,3 +465,62 @@ async def get_static_map(resource_id: str, request: Request): raise HTTPException(status_code=500, detail="Failed to retrieve static map") from e return _validated_static_asset_response(map_data, request) + + +@router.get("/static-maps/{resource_id}/geometry/native", response_class=Response) +async def get_native_resource_geometry( + resource_id: str, request: Request, size: Literal["full", "thumbnail"] = "full" +): + """Versioned native geography; never reuse legacy CARTO hashes or aliases.""" + from app.services.native_static_maps import renderer_url + from app.tasks.worker import celery_app + + if not renderer_url(): + raise HTTPException( + status_code=503, + detail="Native renderer is not configured", + headers={"Cache-Control": "no-store", "Retry-After": "30"}, + ) + resource = await _fetch_resource_dict(resource_id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + service = StaticMapService(384, 288) if size == "thumbnail" else StaticMapService() + geometry = _resource_geometry(resource) + signature = service.geometry_signature(geometry) + digest = await service.get_asset_hash( + resource_id, + variant=service.geometry_variant(), + source_signature=signature, + hydrate_asset=False, + ) + if digest: + url = await asyncio.to_thread(get_visual_asset_public_url, digest) + if url: + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) + data = await service.get_cached_asset(digest) + if data: + return _validated_static_asset_response( + data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) + from app.services.native_static_maps import reuse_geometry + + data = await asyncio.to_thread(reuse_geometry, service, resource_id, geometry) + if data: + return _validated_static_asset_response( + data, request, cache_control="public, max-age=3600, stale-while-revalidate=86400" + ) + lock_key = f"native-resource-pending:{resource_id}:{signature}" + acquired = await asyncio.to_thread(service.map_cache.set, lock_key, "pending", nx=True, ex=600) + if acquired: + try: + await asyncio.to_thread( + celery_app.send_task, + "generate_native_resource_map", + args=[resource_id, geometry, lock_key, service.map_width, service.map_height], + ) + except Exception: + await asyncio.to_thread(service.map_cache.delete, lock_key) + raise + return Response(status_code=503, headers={"Cache-Control": "no-store", "Retry-After": "2"}) diff --git a/backend/app/api/v1/endpoint_modules/thumbnails.py b/backend/app/api/v1/endpoint_modules/thumbnails.py index e0acc43..51699d3 100644 --- a/backend/app/api/v1/endpoint_modules/thumbnails.py +++ b/backend/app/api/v1/endpoint_modules/thumbnails.py @@ -1,3 +1,4 @@ +import asyncio import io import logging import os @@ -15,6 +16,7 @@ ) from app.services.image_service import ImageService from app.services.thumbnail_alias_service import is_thumbnail_hash +from app.services.visual_asset_cache import get_visual_asset_public_url logger = logging.getLogger(__name__) @@ -155,6 +157,14 @@ async def get_thumbnail(resource_id: str, request: Request): if redirect is not None: return redirect + if is_thumbnail_hash(resource_id): + url = await asyncio.to_thread(get_visual_asset_public_url, resource_id) + if url: + # Legacy thumbnail hashes describe sources, so keep the mapping redirect short-lived. + return Response( + status_code=302, headers={"Location": url, "Cache-Control": "public, max-age=300"} + ) + try: # Create service without resource (we only need cache access) image_service = ImageService({}) diff --git a/backend/app/services/image_service.py b/backend/app/services/image_service.py index 2ac40f5..d8111e1 100644 --- a/backend/app/services/image_service.py +++ b/backend/app/services/image_service.py @@ -28,7 +28,11 @@ safe_record_thumbnail_state_sync, thumbnail_state_service, ) -from app.services.visual_asset_cache import cache_visual_asset, get_durable_visual_asset +from app.services.visual_asset_cache import ( + cache_visual_asset, + get_durable_visual_asset, + has_durable_visual_asset, +) # Load environment variables from .env file try: @@ -793,7 +797,7 @@ def has_cached_image_sync(self, image_hash: str) -> bool: return True except Exception as e: self.logger.error(f"Error checking cached image: {e}") - return get_durable_visual_asset(image_hash) is not None + return has_durable_visual_asset(image_hash) def _candidate_cached_thumbnail_hash_sync(self, source_url: str) -> Optional[str]: """Return the immutable thumbnail hash for a cached source URL, if known.""" @@ -1142,14 +1146,15 @@ async def get_cached_image(self, image_hash: str) -> Optional[bytes]: durable = await asyncio.to_thread(get_durable_visual_asset, image_hash) if durable: image_bytes, content_type = durable - await asyncio.to_thread(cache_visual_asset, self.image_cache, image_key, image_bytes) - await asyncio.to_thread( - cache_visual_asset, - self.image_cache, - f"image_type:{image_hash}", - content_type, - ) - self.logger.debug(f"Rehydrated cached image {image_hash} from durable store") + try: + await asyncio.to_thread( + cache_visual_asset, self.image_cache, image_key, image_bytes + ) + await asyncio.to_thread( + cache_visual_asset, self.image_cache, f"image_type:{image_hash}", content_type + ) + except Exception as exc: + self.logger.warning("Optional Redis image hydration failed: %s", type(exc).__name__) return image_bytes return None @@ -1162,7 +1167,7 @@ async def has_cached_image(self, image_hash: str) -> bool: return True except Exception as e: self.logger.error(f"Error checking cached image: {e}") - return await asyncio.to_thread(get_durable_visual_asset, image_hash) is not None + return await asyncio.to_thread(has_durable_visual_asset, image_hash) def is_pmtiles_skip_cached(self, image_hash: str) -> bool: """ diff --git a/backend/app/services/native_static_maps.py b/backend/app/services/native_static_maps.py new file mode 100644 index 0000000..20e1218 --- /dev/null +++ b/backend/app/services/native_static_maps.py @@ -0,0 +1,175 @@ +"""Optional private MapLibre renderer; storage remains in StaticMapService.""" + +import hashlib +import io +import json +import os +import uuid + +import requests +from PIL import Image + +from app.services.visual_asset_cache import ( + cache_visual_asset, + get_durable_visual_asset, + get_durable_visual_asset_hash_for_resource, + get_durable_visual_asset_hash_for_signature, +) + + +def renderer_url(): + return os.getenv("STATIC_MAP_RENDERER_URL", "").rstrip("/") + + +def revision(): + # Bump when styles, tiles, fonts, or renderer settings change. + return os.getenv("STATIC_MAP_RENDER_REVISION", "openfreemap-liberty-native-v1") + + +def signature(value): + return hashlib.sha256(json.dumps([revision(), value], sort_keys=True).encode()).hexdigest() + + +def _generate_uncached( + service, + resource_id, + geometry=None, + *, + overlay=True, + center=None, + zoom=None, + source_signature=None, + hydrate_asset=True, +): + spec = {"width": service.map_width, "height": service.map_height} + no_geometry = False + if center is not None: + spec.update(center=center, leafletZoom=zoom) + else: + bounds = service._parse_geometry_to_bbox(geometry) + no_geometry = not bounds or service._is_global_bbox(bounds) + if no_geometry: + spec.update(center=[0, 0], leafletZoom=2) + elif bounds[0] == bounds[2] and bounds[1] == bounds[3]: + spec.update(center=[bounds[0], bounds[1]], leafletZoom=12) + else: + bounds = service._renderable_bbox(bounds) + if not bounds: + raise ValueError("Geometry has no renderable bounds") + spec["bounds"] = list(bounds) + if overlay and not no_geometry: + geojson = service._geometry_to_geojson_dict(geometry) + if geojson is None: + w, s, e, n = bounds + geojson = { + "type": "Polygon", + "coordinates": [[[w, s], [e, s], [e, n], [w, n], [w, s]]], + } + spec["geometry"] = geojson + response = requests.post(renderer_url() + "/render", json=spec, timeout=(5, 75)) + response.raise_for_status() + data = response.content + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("Renderer did not return PNG") + with Image.open(io.BytesIO(data)) as image: + if image.size != (service.map_width, service.map_height): + raise ValueError("Renderer returned incorrect dimensions") + image.verify() + if overlay and no_geometry: + image = service._overlay_no_data_symbol(Image.open(io.BytesIO(data))) + output = io.BytesIO() + image.save(output, "PNG") + data = output.getvalue() + variant = service.geometry_variant() if overlay else service.basemap_variant() + source_signature = source_signature or service.geometry_signature(geometry) + digest = service.materialize_asset_sync( + resource_id, + variant=variant, + map_bytes=data, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + if not digest or get_durable_visual_asset(digest) != (data, "image/png"): + raise RuntimeError("Could not persist rendered asset") + if not resource_id.startswith("institution:") and ( + get_durable_visual_asset_hash_for_resource( + resource_id, asset_kind=variant, source_signature=source_signature + ) + != digest + ): + raise RuntimeError("Could not link rendered asset") + if hydrate_asset and not cache_visual_asset( + service.map_cache, + service._cache_key(resource_id, variant=variant, source_signature=source_signature), + data, + ): + raise RuntimeError("Could not cache rendered asset") + return data + + +def reuse_geometry( + service, resource_id, geometry, *, overlay=True, source_signature=None, hydrate_asset=True +): + variant = service.geometry_variant() if overlay else service.basemap_variant() + source_signature = source_signature or service.geometry_signature(geometry) + digest = get_durable_visual_asset_hash_for_signature(variant, source_signature) + if not digest: + return None + data = service.get_cached_asset_sync(digest) + if not data: + return None + linked = service.materialize_asset_sync( + resource_id, + variant=variant, + map_bytes=data, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + if ( + linked != digest + or get_durable_visual_asset_hash_for_resource( + resource_id, asset_kind=variant, source_signature=source_signature + ) + != digest + ): + raise RuntimeError("Could not link reusable map") + return data + + +def generate(service, resource_id, geometry=None, **kwargs): + # Campus cameras use a separate identity and do not have resource foreign keys. + if resource_id.startswith("institution:") or kwargs.get("center") is not None: + return _generate_uncached(service, resource_id, geometry, **kwargs) + reuse_args = { + key: kwargs[key] + for key in ("overlay", "source_signature", "hydrate_asset") + if key in kwargs + } + data = reuse_geometry(service, resource_id, geometry, **reuse_args) + if data: + return data + sig = kwargs.get("source_signature") or service.geometry_signature(geometry) + kind = service.geometry_variant() if kwargs.get("overlay", True) else service.basemap_variant() + lock = f"native-render:{kind}:{sig}" + token = uuid.uuid4().hex + if not service.map_cache.set(lock, token, nx=True, ex=120): + raise RuntimeError("Identical geometry is already rendering; retry") + try: + # Another worker may have finished between the first lookup and locking. + data = reuse_geometry(service, resource_id, geometry, **reuse_args) + return data or _generate_uncached(service, resource_id, geometry, **kwargs) + finally: + service.map_cache.eval( + "if redis.call('get',KEYS[1]) == ARGV[1] then " + "return redis.call('del',KEYS[1]) else return 0 end", + 1, + lock, + token, + ) + + +def pregenerate_thumbnail(resource_id, geometry, *, hydrate_asset=True): + from app.services.static_map_service import StaticMapService + + service = StaticMapService(384, 288) + return generate(service, resource_id, geometry, hydrate_asset=hydrate_asset) diff --git a/backend/app/services/static_map_service.py b/backend/app/services/static_map_service.py index 043ff5e..e875fa1 100644 --- a/backend/app/services/static_map_service.py +++ b/backend/app/services/static_map_service.py @@ -23,10 +23,13 @@ from shapely import wkt as shapely_wkt from shapely.geometry import shape +from app.services import native_static_maps from app.services.visual_asset_cache import ( cache_visual_asset, + durable_visual_asset_enabled, get_durable_visual_asset, get_durable_visual_asset_hash_for_resource, + has_durable_visual_asset, redis_operation_with_loading_retry, store_durable_visual_asset, store_durable_visual_asset_link, @@ -83,6 +86,9 @@ def __init__(self, map_width: int = 800, map_height: int = 600): map_width: Width of generated maps in pixels (default: 800) map_height: Height of generated maps in pixels (default: 600) """ + if native_static_maps.renderer_url(): + self._MAP_VARIANT = "native_geometry_v1" + self._BASEMAP_VARIANT = "native_basemap_v1" self.map_width = map_width self.map_height = map_height @@ -491,7 +497,14 @@ def geometry_signature(self, geometry: Any) -> str: separators=(",", ":"), default=str, ) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return ( + native_static_maps.signature( + [normalized, self.map_width, self.map_height] + + (["compact-credits-v1"] if self.map_width < 500 else []) + ) + if native_static_maps.renderer_url() + else hashlib.sha256(normalized.encode("utf-8")).hexdigest() + ) def centered_basemap_signature(self, *, latitude: float, longitude: float, zoom: int) -> str: """Return a stable content signature for a campus-centered basemap request.""" @@ -504,7 +517,11 @@ def centered_basemap_signature(self, *, latitude: float, longitude: float, zoom: sort_keys=True, separators=(",", ":"), ) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() + return ( + native_static_maps.signature([payload, self.map_width, self.map_height]) + if native_static_maps.renderer_url() + else hashlib.sha256(payload.encode("utf-8")).hexdigest() + ) def external_static_map_url( self, @@ -585,6 +602,12 @@ def _clean_external_static_map_url(self, url: Optional[str]) -> Optional[str]: return cleaned return None + @staticmethod + def campus_asset_hash(resource_id: str, variant: str, source_signature: str | None) -> str: + """Stable campus camera identity without a resource-table foreign key.""" + value = json.dumps([resource_id, variant, source_signature], separators=(",", ":")) + return hashlib.sha256(value.encode()).hexdigest() + def get_asset_hash_sync( self, resource_id: str, @@ -601,6 +624,10 @@ def get_asset_hash_sync( if hot_hash: return hot_hash + if resource_id.startswith("institution:"): + identity = self.campus_asset_hash(resource_id, variant, source_signature) + return identity if has_durable_visual_asset(identity) else None + value = get_durable_visual_asset_hash_for_resource( resource_id, asset_kind=variant, @@ -769,20 +796,19 @@ async def delete_asset_hash( ) def has_cached_asset_sync(self, map_hash: str) -> bool: - if not self.map_cache or not self._is_asset_hash(map_hash): + if not self._is_asset_hash(map_hash): return False - - try: - asset_key = self._asset_key(map_hash) - return bool( - redis_operation_with_loading_retry( + if self.map_cache: + try: + asset_key = self._asset_key(map_hash) + if redis_operation_with_loading_retry( lambda: self.map_cache.exists(asset_key), operation_name=f"check static map asset {asset_key}", - ) - ) - except Exception as e: - logger.error(f"Error checking cached static map asset {map_hash}: {e}") - return False + ): + return True + except Exception as exc: + logger.warning("Redis asset check failed: %s", type(exc).__name__) + return has_durable_visual_asset(map_hash) async def has_cached_asset(self, map_hash: str) -> bool: return await asyncio.to_thread(self.has_cached_asset_sync, map_hash) @@ -835,28 +861,39 @@ def materialize_asset_sync( if not map_bytes: return None - map_hash = self._asset_hash(map_bytes) - if hydrate_asset: - self.cache_asset_sync(map_hash, map_bytes) - store_durable_visual_asset( + is_campus = resource_id.startswith("institution:") + map_hash = ( + self.campus_asset_hash(resource_id, variant, source_signature) + if is_campus + else self._asset_hash(map_bytes) + ) + stored = store_durable_visual_asset( map_hash, asset_kind=variant, content_type=self._asset_content_type(map_bytes), body=map_bytes, ) - store_durable_visual_asset_link( - resource_id, - asset_hash=map_hash, - asset_kind=variant, - source_signature=source_signature, - ) - if source_signature: - store_durable_visual_asset_link( + if durable_visual_asset_enabled() and not stored: + return None + if stored and not is_campus: + if not store_durable_visual_asset_link( + resource_id, + asset_hash=map_hash, + asset_kind=variant, + source_signature=source_signature, + ): + return None + if source_signature and not store_durable_visual_asset_link( resource_id, asset_hash=map_hash, asset_kind=variant, source_signature=None, - ) + ): + return None + if hydrate_asset: + cached = self.cache_asset_sync(map_hash, map_bytes) + if not stored and not cached: + return None self.set_asset_hash_sync( resource_id, variant=variant, @@ -1279,6 +1316,16 @@ def generate_map( Returns: PNG image bytes if successful, None if generation failed """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + geometry, + overlay=True, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: # Parse bbox for skip-global check and for fallback rectangle bbox_coords = self._parse_geometry_to_bbox(geometry) @@ -1383,6 +1430,16 @@ def generate_global_map( Generate a world-view static map (no bbox) for resources with no geometry. Uses the same tile layer and cache key as generate_map. """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + None, + overlay=True, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: context = self._global_map_context() return self._render_and_cache( @@ -1413,6 +1470,16 @@ def generate_basemap( """ Generate a basemap-only image using the resource extent with no visible geometry overlay. """ + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + geometry, + overlay=False, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: bbox_coords = self._parse_geometry_to_bbox(geometry) if not bbox_coords: @@ -1496,6 +1563,16 @@ def generate_global_basemap( hydrate_asset: bool = True, ) -> Optional[bytes]: """Generate a world-view basemap image with no geometry overlay.""" + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + None, + overlay=False, + source_signature=source_signature, + hydrate_asset=hydrate_asset, + ) + try: context = self._global_map_context() return self._render_and_cache( @@ -1524,6 +1601,19 @@ def generate_centered_basemap( source_signature: str | None = None, ) -> Optional[bytes]: """Generate a basemap centered on a specific lat/lon at a fixed zoom.""" + if native_static_maps.renderer_url(): + return native_static_maps.generate( + self, + resource_id, + overlay=False, + center=[longitude, latitude], + zoom=zoom, + source_signature=source_signature + or self.centered_basemap_signature( + latitude=latitude, longitude=longitude, zoom=zoom + ), + ) + try: if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180): logger.warning( diff --git a/backend/app/services/visual_asset_cache.py b/backend/app/services/visual_asset_cache.py index ec54a70..7782717 100644 --- a/backend/app/services/visual_asset_cache.py +++ b/backend/app/services/visual_asset_cache.py @@ -3,9 +3,15 @@ import time from typing import Any, Callable, TypeVar -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert +from app.services.visual_asset_object_store import ( + public_visual_asset_url, + read_visual_asset, + s3_visual_assets_enabled, + upload_visual_asset, +) from db.models import generated_visual_asset_links, generated_visual_assets from db.session import sync_engine as app_sync_engine @@ -14,13 +20,14 @@ def visual_asset_ttl_seconds() -> int: - """TTL for generated thumbnail/static-map bytes. + """Bound hot-cache retention in object-storage mode; preserve legacy defaults.""" + default = "86400" if s3_visual_assets_enabled() else "0" + return int(os.getenv("VISUAL_ASSET_CACHE_TTL_SECONDS", default)) - A value <= 0 stores visual assets without Redis expiry. These assets are - content-addressed or source-signature addressed, so routine TTL eviction is - counterproductive once the priming tasks have made them hot. - """ - return int(os.getenv("VISUAL_ASSET_CACHE_TTL_SECONDS", "0")) + +def redis_visual_body_cache_enabled() -> bool: + default = "false" if s3_visual_assets_enabled() else "true" + return os.getenv("VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED", default).lower() == "true" def _env_float(name: str, default: float) -> float: @@ -85,6 +92,10 @@ def redis_operation_with_loading_retry( def cache_visual_asset(cache: Any, key: str, value: bytes | str) -> bool: + # A disabled optional binary cache is a successful no-op, including renderer callers. + # Alias strings still receive a finite TTL in S3 mode. + if isinstance(value, bytes) and not redis_visual_body_cache_enabled(): + return True ttl = visual_asset_ttl_seconds() if ttl > 0: return bool( @@ -102,7 +113,7 @@ def cache_visual_asset(cache: Any, key: str, value: bytes | str) -> bool: def durable_visual_asset_enabled() -> bool: - return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() == "database" + return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() in {"database", "s3", "r2"} def _sync_engine(): @@ -126,6 +137,21 @@ def store_durable_visual_asset( "body": body, "byte_size": len(body), } + if s3_visual_assets_enabled(): + try: + key, digest = upload_visual_asset(body, content_type) + except Exception as exc: + logger.warning( + "Visual asset upload failed for %s: %s", asset_hash[:12], type(exc).__name__ + ) + return False + values.update(object_key=key, object_sha256=digest) + # Dual-write is the default for a reversible pilot. Disabling it affects NEW writes only. + if os.getenv("VISUAL_ASSET_S3_RETAIN_DATABASE_BODY", "true").lower() != "true": + values["body"] = None + else: + # Database-only writes must never retain a stale pointer to previous bytes. + values.update(object_key=None, object_sha256=None) stmt = pg_insert(generated_visual_assets).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[generated_visual_assets.c.asset_hash], @@ -134,6 +160,8 @@ def store_durable_visual_asset( "content_type": stmt.excluded.content_type, "body": stmt.excluded.body, "byte_size": stmt.excluded.byte_size, + "object_key": stmt.excluded.object_key, + "object_sha256": stmt.excluded.object_sha256, }, ) try: @@ -211,20 +239,91 @@ def get_durable_visual_asset_hash_for_resource( return str(row._mapping["asset_hash"]) -def get_durable_visual_asset(asset_hash: str) -> tuple[bytes, str] | None: +def _visual_asset_metadata(asset_hash: str): if not durable_visual_asset_enabled() or not asset_hash: return None - stmt = select( - generated_visual_assets.c.body, generated_visual_assets.c.content_type, + generated_visual_assets.c.byte_size, + generated_visual_assets.c.object_key, + generated_visual_assets.c.object_sha256, + generated_visual_assets.c.body.is_not(None).label("has_body"), ).where(generated_visual_assets.c.asset_hash == asset_hash) + with _sync_engine().connect() as conn: + return conn.execute(stmt).mappings().first() + + +def get_visual_asset_public_url(asset_hash: str) -> str | None: + """Only expose a CDN URL after the upload and database pointer have committed.""" + if not s3_visual_assets_enabled(): + return None + try: + row = _visual_asset_metadata(asset_hash) + if row and row["object_key"] and row["object_sha256"]: + return public_visual_asset_url(row["object_key"]) + except Exception as exc: + logger.warning("Unable to resolve visual asset URL: %s", type(exc).__name__) + return None + + +def has_durable_visual_asset(asset_hash: str) -> bool: + """Check committed metadata without downloading an image on every alias lookup.""" + if not durable_visual_asset_enabled() or not asset_hash: + return False + stmt = select(generated_visual_assets.c.asset_hash).where( + generated_visual_assets.c.asset_hash == asset_hash, + or_( + generated_visual_assets.c.body.is_not(None), + generated_visual_assets.c.object_key.is_not(None) + & generated_visual_assets.c.object_sha256.is_not(None), + ), + ) try: with _sync_engine().connect() as conn: - row = conn.execute(stmt).fetchone() + return conn.execute(stmt).first() is not None except Exception as exc: - logger.warning("Failed to load durable visual asset %s: %s", asset_hash[:12], exc) + logger.warning("Unable to check durable visual asset: %s", type(exc).__name__) + return False + + +def get_durable_visual_asset(asset_hash: str) -> tuple[bytes, str] | None: + if not durable_visual_asset_enabled() or not asset_hash: return None - if not row: + try: + row = _visual_asset_metadata(asset_hash) + if not row: + return None + if row["object_key"] and row["object_sha256"]: + try: + body = read_visual_asset(row["object_key"], row["object_sha256"], row["byte_size"]) + return body, str(row["content_type"]) + except Exception as exc: + logger.warning("Object read failed; trying database body: %s", type(exc).__name__) + stmt = select(generated_visual_assets.c.body, generated_visual_assets.c.content_type).where( + generated_visual_assets.c.asset_hash == asset_hash + ) + with _sync_engine().connect() as conn: + local = conn.execute(stmt).mappings().first() + if local and local["body"]: + return bytes(local["body"]), str(local["content_type"]) + except Exception as exc: + logger.warning("Failed to load visual asset %s: %s", asset_hash[:12], type(exc).__name__) + return None + + +def get_durable_visual_asset_hash_for_signature( + asset_kind: str, source_signature: str +) -> str | None: + """Find reusable render bytes without tying identity to a catalog record.""" + if not durable_visual_asset_enabled() or not source_signature: return None - return (bytes(row._mapping["body"]), str(row._mapping["content_type"])) + stmt = ( + select(generated_visual_asset_links.c.asset_hash) + .where( + generated_visual_asset_links.c.asset_kind == asset_kind, + generated_visual_asset_links.c.source_signature == source_signature, + ) + .limit(1) + ) + with _sync_engine().connect() as conn: + return conn.execute(stmt).scalar_one_or_none() diff --git a/backend/app/services/visual_asset_object_store.py b/backend/app/services/visual_asset_object_store.py new file mode 100644 index 0000000..0f0a907 --- /dev/null +++ b/backend/app/services/visual_asset_object_store.py @@ -0,0 +1,107 @@ +"""S3-compatible storage for immutable generated images (including Cloudflare R2).""" + +import base64 +import hashlib +import os +from functools import lru_cache +from urllib.parse import quote, urlsplit + +import boto3 +from botocore.config import Config + + +def s3_visual_assets_enabled() -> bool: + return os.getenv("VISUAL_ASSET_DURABLE_STORE", "database").lower() in {"s3", "r2"} + + +def _required(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise ValueError(f"{name} must be configured for visual object storage") + return value + + +@lru_cache(maxsize=1) +def _client(process_id: int): + # Celery forks: create a separate connection pool in each worker process. + endpoint = _required("VISUAL_ASSET_S3_ENDPOINT") + if urlsplit(endpoint).scheme != "https": + raise ValueError("Visual object storage requires an HTTPS endpoint") + return boto3.client( + "s3", + endpoint_url=endpoint, + region_name=os.getenv("VISUAL_ASSET_S3_REGION", "auto"), + aws_access_key_id=_required("VISUAL_ASSET_S3_ACCESS_KEY_ID"), + aws_secret_access_key=_required("VISUAL_ASSET_S3_SECRET_ACCESS_KEY"), + config=Config( + signature_version="s3v4", + connect_timeout=5, + read_timeout=20, + retries={"total_max_attempts": 3, "mode": "standard"}, + s3={"addressing_style": "path"}, + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + ), + ) + + +def client(): + return _client(os.getpid()) + + +def bucket() -> str: + return _required("VISUAL_ASSET_S3_BUCKET") + + +def object_key_for_body(body: bytes) -> tuple[str, str]: + digest = hashlib.sha256(body).hexdigest() + prefix = os.getenv("VISUAL_ASSET_S3_PREFIX", "assets").strip("/") + key = f"{prefix}/{digest}" if prefix else digest + return key, digest + + +def upload_visual_asset(body: bytes, content_type: str) -> tuple[str, str]: + """Publish bytes with a transport checksum; verify metadata before recording a pointer.""" + if not body or not content_type.startswith("image/"): + raise ValueError("Only nonempty generated image bodies may be uploaded") + key, digest = object_key_for_body(body) + s3 = client() + name = bucket() + # Repeated uploads have identical bytes and keys, including concurrent renders. + s3.put_object( + Bucket=name, + Key=key, + Body=body, + ContentType=content_type, + ContentMD5=base64.b64encode(hashlib.md5(body).digest()).decode("ascii"), + CacheControl="public, max-age=31536000, immutable", + Metadata={"sha256": digest}, + ) + head = s3.head_object(Bucket=name, Key=key) + if ( + head["ContentLength"] != len(body) + or head.get("Metadata", {}).get("sha256") != digest + or head.get("ContentType") != content_type + ): + raise RuntimeError("Uploaded visual asset metadata verification failed") + return key, digest + + +def read_visual_asset(key: str, digest: str, byte_size: int) -> bytes: + response = client().get_object(Bucket=bucket(), Key=key) + with response["Body"] as stream: + # Read one extra byte to detect unexpected data without unbounded allocation. + body = stream.read(byte_size + 1) + if len(body) != byte_size or hashlib.sha256(body).hexdigest() != digest: + raise RuntimeError("Visual asset checksum verification failed") + return body + + +def public_visual_asset_url(key: str) -> str | None: + base = os.getenv("VISUAL_ASSET_PUBLIC_BASE_URL", "").strip().rstrip("/") + if not base: + return None + parsed = urlsplit(base) + if parsed.scheme != "https" or not parsed.netloc or parsed.query or parsed.fragment: + raise ValueError("VISUAL_ASSET_PUBLIC_BASE_URL must be an HTTPS base URL") + return f"{base}/{quote(key, safe='/')}" diff --git a/backend/app/tasks/static_maps.py b/backend/app/tasks/static_maps.py index fbde16a..05875d5 100644 --- a/backend/app/tasks/static_maps.py +++ b/backend/app/tasks/static_maps.py @@ -34,6 +34,10 @@ async def generate_map_for_resource(resource_id: str, geometry: Any) -> bool: bool: True if the map was generated successfully, False otherwise """ try: + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + + if renderer_url(): + await asyncio.to_thread(pregenerate_thumbnail, resource_id, geometry) service = StaticMapService() source_signature = service.geometry_signature(geometry) @@ -267,3 +271,65 @@ async def _generate_static_maps_collection_async() -> dict: finally: await task_engine.dispose() + + +@shared_task(bind=True, name="generate_campus_static_map", max_retries=3) +def generate_campus_static_map(self, map_id, latitude, longitude, zoom, lock_key): + """Generate a campus PNG outside the web request; retry remote service failures.""" + service = StaticMapService(map_width=640, map_height=320) + try: + data = service.generate_centered_basemap( + f"institution:{map_id}", latitude=latitude, longitude=longitude, zoom=zoom + ) + if not data: + raise RuntimeError("Campus renderer returned no image") + service.map_cache.delete(lock_key) + return {"success": True, "sha256": service._asset_hash(data)} + except Exception as exc: + if self.request.retries >= self.max_retries: + service.map_cache.delete(lock_key) + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + + +@shared_task(bind=True, name="generate_native_resource_map", max_retries=3) +def generate_native_resource_map(self, resource_id, geometry, lock_key, width=800, height=600): + """Queue native geography for HTTP cache misses without serving legacy images.""" + from app.services.native_static_maps import renderer_url + + service = StaticMapService(width, height) + try: + if not renderer_url(): + raise RuntimeError("Native renderer is not configured") + data = service.generate_map(resource_id, geometry) + if not data: + raise RuntimeError("Native renderer returned no image") + service.map_cache.delete(lock_key) + return {"success": True, "sha256": service._asset_hash(data)} + except Exception as exc: + if self.request.retries >= self.max_retries: + service.map_cache.delete(lock_key) + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + + +@shared_task(bind=True, name="pregenerate_native_thumbnails", max_retries=3) +def pregenerate_native_thumbnails(self, resource_ids): + """Bounded, repeatable catalog backfill. Existing renders are linked/reused.""" + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + from db.session import sync_engine + + if not renderer_url(): + raise RuntimeError("Native renderer is not configured") + if len(resource_ids) > 100: + raise ValueError("Submit at most 100 records per batch") + with sync_engine.connect() as conn: + rows = conn.execute( + select(resources.c.id, resources.c.locn_geometry, resources.c.dcat_bbox).where( + resources.c.id.in_(resource_ids) + ) + ).fetchall() + try: + for row in rows: + pregenerate_thumbnail(row.id, row.locn_geometry or row.dcat_bbox) + except Exception as exc: + raise self.retry(exc=exc, countdown=5 * (self.request.retries + 1)) from exc + return {"prepared": len(rows), "missing": len(set(resource_ids)) - len(rows)} diff --git a/backend/db/migrations/add_visual_asset_object_storage.py b/backend/db/migrations/add_visual_asset_object_storage.py new file mode 100644 index 0000000..f60f938 --- /dev/null +++ b/backend/db/migrations/add_visual_asset_object_storage.py @@ -0,0 +1,29 @@ +"""Add object-storage pointers while preserving all existing visual asset bytes.""" + +from sqlalchemy import text + + +def add_visual_asset_object_storage(engine=None): + if engine is None: + from db.session import sync_engine + + engine = sync_engine + with engine.begin() as conn: + conn.execute(text("SET LOCAL lock_timeout = '5s'")) + conn.execute( + text( + "ALTER TABLE generated_visual_assets " + "ADD COLUMN IF NOT EXISTS object_key VARCHAR(1024)" + ) + ) + conn.execute( + text( + "ALTER TABLE generated_visual_assets " + "ADD COLUMN IF NOT EXISTS object_sha256 VARCHAR(64)" + ) + ) + conn.execute(text("ALTER TABLE generated_visual_assets ALTER COLUMN body DROP NOT NULL")) + + +if __name__ == "__main__": + add_visual_asset_object_storage() diff --git a/backend/db/models.py b/backend/db/models.py index 7438e6c..3850a1b 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -355,8 +355,10 @@ Column("asset_hash", String(64), primary_key=True), Column("asset_kind", String(64), nullable=False, index=True), Column("content_type", String(255), nullable=False), - Column("body", LargeBinary, nullable=False), + Column("body", LargeBinary, nullable=True), Column("byte_size", Integer, nullable=False), + Column("object_key", String(1024), nullable=True), + Column("object_sha256", String(64), nullable=True), Column("created_at", TIMESTAMP, nullable=False, server_default=func.now()), Column("updated_at", TIMESTAMP, nullable=False, server_default=func.now()), ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d2938b9..f978177 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "anyio==4.7.0", "async-timeout==5.0.1", "asyncpg==0.30.0", + "boto3==1.43.119", "click==8.1.7", "databases==0.9.0", "exceptiongroup==1.2.2", diff --git a/backend/scripts/backfill_visual_assets_to_s3.py b/backend/scripts/backfill_visual_assets_to_s3.py new file mode 100644 index 0000000..3a82349 --- /dev/null +++ b/backend/scripts/backfill_visual_assets_to_s3.py @@ -0,0 +1,103 @@ +"""Copy durable image bodies to S3/R2 without deleting local data or changing URLs.""" + +import argparse +import json +from concurrent.futures import ThreadPoolExecutor + +from sqlalchemy import select, update + +from app.services.visual_asset_object_store import read_visual_asset, upload_visual_asset +from db.models import generated_visual_assets as assets +from db.session import sync_engine + + +def copy_row(row): + body = bytes(row["body"]) + key, digest = upload_visual_asset(body, row["content_type"]) + read_visual_asset(key, digest, len(body)) + with sync_engine.begin() as conn: + result = conn.execute( + update(assets) + .where( + assets.c.asset_hash == row["asset_hash"], + assets.c.body == body, + assets.c.content_type == row["content_type"], + assets.c.object_key.is_(None), + ) + .values(object_key=key, object_sha256=digest) + ) + return result.rowcount + + +def backfill(*, apply=False, limit=100, after="", workers=1, progress=None): + copied = skipped = scanned = 0 + cursor = after + with ThreadPoolExecutor(max_workers=workers) as pool: + while scanned < limit: + stmt = ( + select(assets.c.asset_hash, assets.c.body, assets.c.content_type) + .where( + assets.c.asset_hash > cursor, + assets.c.body.is_not(None), + assets.c.object_key.is_(None), + ) + .order_by(assets.c.asset_hash) + .limit(min(25, limit - scanned)) + ) + with sync_engine.connect() as conn: + rows = conn.execute(stmt).mappings().all() + if not rows: + break + if apply: + # Serial default keeps embedded-database tests and small pilots simple. + results = map(copy_row, rows) if workers == 1 else pool.map(copy_row, rows) + for changed in results: + copied += changed + skipped += 1 - changed + scanned += len(rows) + cursor = rows[-1]["asset_hash"] + if progress and scanned % 500 == 0: + progress( + { + "scanned": scanned, + "copied": copied, + "concurrently_changed": skipped, + "last_hash": cursor, + } + ) + return { + "scanned": scanned, + "copied": copied, + "concurrently_changed": skipped, + "last_hash": cursor, + "dry_run": not apply, + "local_bodies_deleted": 0, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--limit", type=int, default=100) + parser.add_argument("--after", default="") + parser.add_argument("--workers", type=int, default=1) + args = parser.parse_args() + if args.limit < 1: + parser.error("--limit must be positive") + if not 1 <= args.workers <= 16: + parser.error("--workers must be between 1 and 16") + print( + json.dumps( + backfill( + apply=args.apply, + limit=args.limit, + after=args.after, + workers=args.workers, + progress=lambda data: print(json.dumps(data), flush=True), + ) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/prime_static_map_cache.py b/backend/scripts/prime_static_map_cache.py index 75903d5..bd6f645 100644 --- a/backend/scripts/prime_static_map_cache.py +++ b/backend/scripts/prime_static_map_cache.py @@ -135,6 +135,10 @@ def _prime_static_maps_sync( *, hydrate_assets: bool = True, ) -> tuple[str, str]: + from app.services.native_static_maps import pregenerate_thumbnail, renderer_url + + if renderer_url(): + pregenerate_thumbnail(resource_id, geometry, hydrate_asset=hydrate_assets) service = StaticMapService() source_signature = service.geometry_signature(geometry) static_map_variant = service.geometry_variant() diff --git a/backend/scripts/run_migrations.py b/backend/scripts/run_migrations.py index c105698..ead3881 100644 --- a/backend/scripts/run_migrations.py +++ b/backend/scripts/run_migrations.py @@ -4,6 +4,7 @@ from db.migrations.add_enrichment_type import add_enrichment_type_column from db.migrations.add_fast_gazetteer import add_fast_gazetteer +from db.migrations.add_visual_asset_object_storage import add_visual_asset_object_storage from db.migrations.api_rate_limiting import init_api_rate_limiting from db.migrations.backfill_resources_from_legacy_items import ( backfill_resources_from_legacy_items, @@ -70,6 +71,7 @@ def run_migrations(): logger.info("Creating generated visual asset cache tables...") create_generated_visual_assets_table() + add_visual_asset_object_storage() logger.info("Creating resource thumbnail state table...") create_resource_thumbnail_state_table() diff --git a/backend/tests/api/v1/test_static_map_endpoints.py b/backend/tests/api/v1/test_static_map_endpoints.py index d702365..8b07d7d 100644 --- a/backend/tests/api/v1/test_static_map_endpoints.py +++ b/backend/tests/api/v1/test_static_map_endpoints.py @@ -45,6 +45,7 @@ def create_static_map_service_mock(*, basemap_bytes=None, geometry_bytes=None) - svc.get_cached_basemap = AsyncMock(return_value=basemap_bytes) svc.get_cached_map = AsyncMock(return_value=geometry_bytes) svc.get_cached_asset = AsyncMock(return_value=None) + svc.get_asset_hash = AsyncMock(return_value=None) svc.basemap_variant.return_value = "basemap" svc.geometry_variant.return_value = "geometry" svc.geometry_signature.return_value = "geometry-signature" diff --git a/backend/tests/scripts/test_prime_static_map_cache.py b/backend/tests/scripts/test_prime_static_map_cache.py index 64c7381..ac025d9 100644 --- a/backend/tests/scripts/test_prime_static_map_cache.py +++ b/backend/tests/scripts/test_prime_static_map_cache.py @@ -168,3 +168,25 @@ async def test_run_refuses_full_corpus_redis_asset_hydration(): with patch.object(prime_static_map_cache, "_count_resources", return_value=1000): assert await prime_static_map_cache._run(args) == 2 + + +def test_native_thumbnail_is_prepared_even_when_full_maps_are_cached(): + service = MagicMock() + service.materialize_cached_variant_sync.return_value = "cached-hash" + with ( + patch.object(prime_static_map_cache, "StaticMapService", return_value=service), + patch("app.services.native_static_maps.renderer_url", return_value="http://renderer"), + patch("app.services.native_static_maps.pregenerate_thumbnail") as prepare, + ): + status, _ = prime_static_map_cache._prime_static_maps_sync( + "resource-1", + "ENVELOPE(-1, 1, 1, -1)", + force=False, + hydrate_assets=False, + ) + assert status == "cached" + prepare.assert_called_once_with( + "resource-1", + "ENVELOPE(-1, 1, 1, -1)", + hydrate_asset=False, + ) diff --git a/backend/tests/services/test_native_static_maps.py b/backend/tests/services/test_native_static_maps.py new file mode 100644 index 0000000..796eb87 --- /dev/null +++ b/backend/tests/services/test_native_static_maps.py @@ -0,0 +1,146 @@ +import io +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from PIL import Image + +from app.api.v1.endpoint_modules.static_maps import router +from app.services import native_static_maps +from app.services.static_map_service import StaticMapService + + +def test_native_signatures_change_with_revision_and_dimensions(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + small = StaticMapService(640, 320) + first = small.centered_basemap_signature(latitude=39, longitude=-86, zoom=15) + assert first != StaticMapService(800, 600).centered_basemap_signature( + latitude=39, longitude=-86, zoom=15 + ) + monkeypatch.setenv("STATIC_MAP_RENDER_REVISION", "new-style") + assert first != small.centered_basemap_signature(latitude=39, longitude=-86, zoom=15) + assert small.basemap_variant() == "native_basemap_v1" + + +def test_native_route_never_falls_back_to_legacy(monkeypatch): + monkeypatch.delenv("STATIC_MAP_RENDERER_URL", raising=False) + app = FastAPI() + app.include_router(router) + with patch.object(StaticMapService, "generate_centered_basemap") as render: + response = TestClient(app).get("/static-maps/institutions/test/native?lat=39&lon=-86") + assert response.status_code == 503 + render.assert_not_called() + + +@pytest.mark.parametrize( + "query", ["lat=nan&lon=0", "lat=90&lon=0", "lat=0&lon=181", "lat=0&lon=0&zoom=30"] +) +def test_campus_rejects_bad_camera(query): + app = FastAPI() + app.include_router(router) + assert TestClient(app).get("/static-maps/institutions/test/native?" + query).status_code == 422 + + +def test_remote_failure_does_not_publish_or_use_carto(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + service = StaticMapService() + service.map_cache = MagicMock() + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps.requests, "post", side_effect=RuntimeError("unavailable")), + patch.object(service, "materialize_asset_sync") as persist, + patch.object(service, "_render_and_cache") as legacy, + ): + with pytest.raises(RuntimeError, match="unavailable"): + service.generate_map("record", "ENVELOPE(-90,-89,40,39)") + persist.assert_not_called() + legacy.assert_not_called() + + +def test_wrong_size_png_is_not_published(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + output = io.BytesIO() + Image.new("RGB", (2, 2)).save(output, "PNG") + response = MagicMock(content=output.getvalue()) + service = StaticMapService() + service.map_cache = MagicMock() + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps.requests, "post", return_value=response), + patch.object(service, "materialize_asset_sync") as persist, + ): + with pytest.raises(ValueError, match="dimensions"): + service.generate_map("record", "ENVELOPE(-90,-89,40,39)") + persist.assert_not_called() + + +def test_native_resource_route_never_falls_back_to_legacy(monkeypatch): + monkeypatch.delenv("STATIC_MAP_RENDERER_URL", raising=False) + app = FastAPI() + app.include_router(router) + with patch.object(StaticMapService, "generate_map") as render: + response = TestClient(app).get("/static-maps/record/geometry/native") + assert response.status_code == 503 + assert response.headers["cache-control"] == "no-store" + render.assert_not_called() + + +def test_thumbnail_uses_small_signature_and_browser_cache(monkeypatch): + monkeypatch.setenv("STATIC_MAP_RENDERER_URL", "http://renderer:8080") + output = io.BytesIO() + Image.new("RGB", (384, 288)).save(output, "PNG") + app = FastAPI() + app.include_router(router) + endpoint = "app.api.v1.endpoint_modules.static_maps" + service = MagicMock() + service.get_asset_hash = AsyncMock(return_value="digest") + service.get_cached_asset = AsyncMock(return_value=output.getvalue()) + with ( + patch( + endpoint + "._fetch_resource_dict", + AsyncMock(return_value={"locn_geometry": "POINT(0 0)"}), + ), + patch(endpoint + ".StaticMapService", return_value=service) as factory, + ): + response = TestClient(app).get("/static-maps/test/geometry/native?size=thumbnail") + factory.assert_called_once_with(384, 288) + assert response.status_code == 200 + assert "max-age=3600" in response.headers["cache-control"] + assert response.content == output.getvalue() + + +def test_identical_geometry_reuses_bytes_without_rendering(): + service = MagicMock() + service.geometry_signature.return_value = "geometry-and-dimensions" + service.geometry_variant.return_value = "native_geometry_v1" + service.get_cached_asset_sync.return_value = b"existing PNG" + service.materialize_asset_sync.return_value = "digest" + with ( + patch.object( + native_static_maps, "get_durable_visual_asset_hash_for_signature", return_value="digest" + ), + patch.object( + native_static_maps, "get_durable_visual_asset_hash_for_resource", return_value="digest" + ), + patch.object(native_static_maps, "_generate_uncached") as render, + ): + assert ( + native_static_maps.generate(service, "different-record", "POINT(1 2)") + == b"existing PNG" + ) + render.assert_not_called() + assert service.materialize_asset_sync.call_args.args[0] == "different-record" + + +def test_concurrent_identical_geometry_is_not_rendered_twice(): + service = MagicMock() + service.map_cache.set.return_value = False + with ( + patch.object(native_static_maps, "reuse_geometry", return_value=None), + patch.object(native_static_maps, "_generate_uncached") as render, + ): + with pytest.raises(RuntimeError, match="already rendering"): + native_static_maps.generate(service, "record", "POINT(1 2)") + render.assert_not_called() + service.map_cache.eval.assert_not_called() diff --git a/backend/tests/services/test_static_map_service.py b/backend/tests/services/test_static_map_service.py index 1919cd6..a22e295 100644 --- a/backend/tests/services/test_static_map_service.py +++ b/backend/tests/services/test_static_map_service.py @@ -50,7 +50,9 @@ def test_materialize_asset_persists_resource_link(): with ( patch.object(service, "set_asset_hash_sync") as mock_set_hash, patch("app.services.static_map_service.store_durable_visual_asset"), - patch("app.services.static_map_service.store_durable_visual_asset_link") as mock_link, + patch( + "app.services.static_map_service.store_durable_visual_asset_link", return_value=True + ) as mock_link, ): asset_hash = service.materialize_asset_sync( "resource-1", diff --git a/backend/tests/services/test_visual_asset_backfill.py b/backend/tests/services/test_visual_asset_backfill.py new file mode 100644 index 0000000..5a5f5af --- /dev/null +++ b/backend/tests/services/test_visual_asset_backfill.py @@ -0,0 +1,85 @@ +"""Exercise copy-only migration against a real, isolated SQL table.""" + +import hashlib +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select, update + +from db.models import generated_visual_assets as assets +from scripts import backfill_visual_assets_to_s3 as migration + + +@pytest.fixture +def database(monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + with engine.begin() as conn: + conn.execute( + assets.insert().values( + asset_hash="a" * 64, + asset_kind="thumbnail", + content_type="image/png", + body=b"original", + byte_size=8, + ) + ) + monkeypatch.setattr(migration, "sync_engine", engine) + yield engine + engine.dispose() + + +def row(engine): + with engine.connect() as conn: + return conn.execute(select(assets)).mappings().one() + + +def test_dry_run_never_uploads_or_changes_body(database): + with patch.object(migration, "upload_visual_asset") as upload: + result = migration.backfill() + assert result["scanned"] == 1 + assert result["copied"] == 0 + assert row(database)["body"] == b"original" + assert row(database)["object_key"] is None + upload.assert_not_called() + + +def test_copy_verifies_bytes_retains_body_and_is_resumable(database): + digest = hashlib.sha256(b"original").hexdigest() + with ( + patch.object(migration, "upload_visual_asset", return_value=("assets/" + digest, digest)), + patch.object(migration, "read_visual_asset", return_value=b"original") as verify, + ): + assert migration.backfill(apply=True)["copied"] == 1 + assert migration.backfill(apply=True)["scanned"] == 0 + verify.assert_called_once_with("assets/" + digest, digest, 8) + assert row(database)["body"] == b"original" + assert row(database)["object_sha256"] == digest + + +def test_failed_verification_never_commits_pointer(database): + with ( + patch.object(migration, "upload_visual_asset", return_value=("assets/hash", "d" * 64)), + patch.object(migration, "read_visual_asset", side_effect=RuntimeError("corrupted")), + pytest.raises(RuntimeError, match="corrupted"), + ): + migration.backfill(apply=True) + assert row(database)["object_key"] is None + assert row(database)["body"] == b"original" + + +def test_concurrent_render_is_not_replaced_with_stale_pointer(database): + def render(*args): + with database.begin() as conn: + conn.execute(update(assets).values(body=b"new image", byte_size=9)) + return "assets/hash", "d" * 64 + + with ( + patch.object(migration, "upload_visual_asset", side_effect=render), + patch.object(migration, "read_visual_asset", return_value=b"original"), + ): + result = migration.backfill(apply=True) + assert result["concurrently_changed"] == 1 + assert result["copied"] == 0 + assert row(database)["object_key"] is None + assert row(database)["body"] == b"new image" diff --git a/backend/tests/services/test_visual_asset_object_store.py b/backend/tests/services/test_visual_asset_object_store.py new file mode 100644 index 0000000..822aa26 --- /dev/null +++ b/backend/tests/services/test_visual_asset_object_store.py @@ -0,0 +1,196 @@ +import hashlib +import io +from unittest.mock import MagicMock, patch + +import pytest + +from app.services import visual_asset_cache as cache +from app.services import visual_asset_object_store as objects + + +@pytest.fixture(autouse=True) +def isolated_settings(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_DURABLE_STORE", "s3") + monkeypatch.setenv("VISUAL_ASSET_S3_BUCKET", "test-images") + monkeypatch.setenv("VISUAL_ASSET_S3_PREFIX", "assets") + monkeypatch.setenv("OGM_MODE", "primary") + monkeypatch.delenv("VISUAL_ASSET_CACHE_TTL_SECONDS", raising=False) + monkeypatch.delenv("VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED", raising=False) + monkeypatch.delenv("VISUAL_ASSET_PUBLIC_BASE_URL", raising=False) + + +def test_upload_uses_actual_bytes_and_verifies_before_returning(): + body = b"generated image" + digest = hashlib.sha256(body).hexdigest() + s3 = MagicMock() + s3.head_object.return_value = { + "ContentLength": len(body), + "ContentType": "image/png", + "Metadata": {"sha256": digest}, + } + with patch.object(objects, "client", return_value=s3): + assert objects.upload_visual_asset(body, "image/png") == (f"assets/{digest}", digest) + args = s3.put_object.call_args.kwargs + assert args["Body"] == body + assert args["ContentMD5"] + assert args["CacheControl"] == "public, max-age=31536000, immutable" + s3.head_object.assert_called_once_with(Bucket="test-images", Key=f"assets/{digest}") + + +def test_incomplete_upload_is_not_published(): + s3 = MagicMock() + s3.head_object.return_value = {"ContentLength": 0} + with patch.object(objects, "client", return_value=s3): + with pytest.raises(RuntimeError, match="verification"): + objects.upload_visual_asset(b"image", "image/png") + + +def test_read_checks_bytes_and_closes_stream_on_corruption(): + stream = io.BytesIO(b"corrupted") + s3 = MagicMock() + s3.get_object.return_value = {"Body": stream} + with patch.object(objects, "client", return_value=s3): + with pytest.raises(RuntimeError, match="checksum"): + objects.read_visual_asset("assets/hash", "0" * 64, 9) + assert stream.closed + + +def test_cache_disabled_for_bodies_but_aliases_expire(): + redis = MagicMock() + assert cache.cache_visual_asset(redis, "image:hash", b"image") + redis.set.assert_not_called() + redis.setex.assert_not_called() + cache.cache_visual_asset(redis, "static_map_alias:id", "hash") + redis.setex.assert_called_once_with("static_map_alias:id", 86400, "hash") + + +def test_database_mode_preserves_legacy_cache_default(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_DURABLE_STORE", "database") + redis = MagicMock() + cache.cache_visual_asset(redis, "image:hash", b"image") + redis.set.assert_called_once_with("image:hash", b"image") + + +def test_failed_upload_never_writes_database_pointer(): + with ( + patch.object(cache, "upload_visual_asset", side_effect=OSError("offline")), + patch.object(cache, "_sync_engine") as engine, + ): + assert not cache.store_durable_visual_asset( + "a" * 64, asset_kind="thumbnail", content_type="image/png", body=b"image" + ) + engine.assert_not_called() + + +def test_s3_only_write_keeps_metadata_without_body(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_S3_RETAIN_DATABASE_BODY", "false") + engine = MagicMock() + with ( + patch.object(cache, "upload_visual_asset", return_value=("assets/digest", "d" * 64)), + patch.object(cache, "_sync_engine", return_value=engine), + ): + assert cache.store_durable_visual_asset( + "a" * 64, asset_kind="thumbnail", content_type="image/png", body=b"image" + ) + stmt = engine.begin.return_value.__enter__.return_value.execute.call_args.args[0] + values = stmt.compile().params + assert values["body"] is None + assert values["byte_size"] == 5 + assert values["object_key"] == "assets/digest" + + +def test_redirect_requires_committed_pointer_and_configured_domain(monkeypatch): + monkeypatch.setenv("VISUAL_ASSET_PUBLIC_BASE_URL", "https://assets.example.org") + with patch.object(cache, "_visual_asset_metadata", return_value=None): + assert cache.get_visual_asset_public_url("a" * 64) is None + with patch.object( + cache, + "_visual_asset_metadata", + return_value={"object_key": "assets/hash", "object_sha256": "d" * 64}, + ): + assert ( + cache.get_visual_asset_public_url("a" * 64) == "https://assets.example.org/assets/hash" + ) + + +def test_object_failure_falls_back_to_existing_database_bytes(): + engine = MagicMock() + conn = engine.connect.return_value.__enter__.return_value + conn.execute.return_value.mappings.return_value.first.return_value = { + "body": b"old-image", + "content_type": "image/png", + } + with ( + patch.object( + cache, + "_visual_asset_metadata", + return_value={ + "object_key": "assets/hash", + "object_sha256": "d" * 64, + "byte_size": 9, + "content_type": "image/png", + }, + ), + patch.object(cache, "read_visual_asset", side_effect=OSError("offline")), + patch.object(cache, "_sync_engine", return_value=engine), + ): + assert cache.get_durable_visual_asset("a" * 64) == (b"old-image", "image/png") + + +@pytest.mark.asyncio +async def test_redis_hydration_failure_does_not_hide_durable_thumbnail(): + from app.services.image_service import ImageService + + service = ImageService.__new__(ImageService) + service.image_cache = MagicMock() + service.image_cache.get.return_value = None + service.logger = MagicMock() + with ( + patch( + "app.services.image_service.get_durable_visual_asset", + return_value=(b"image", "image/png"), + ), + patch("app.services.image_service.cache_visual_asset", side_effect=OSError("Redis full")), + ): + assert await service.get_cached_image("a" * 64) == b"image" + + +def test_static_map_does_not_publish_alias_after_failed_upload(): + from app.services.static_map_service import StaticMapService + + service = StaticMapService.__new__(StaticMapService) + with ( + patch("app.services.static_map_service.store_durable_visual_asset", return_value=False), + patch.object(service, "set_asset_hash_sync") as alias, + patch.object(service, "cache_asset_sync") as hot_cache, + ): + assert ( + service.materialize_asset_sync("resource", variant="static_map_v9", map_bytes=b"image") + is None + ) + alias.assert_not_called() + hot_cache.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["thumbnail", "map"]) +async def test_asset_endpoints_redirect_before_loading_bytes(kind): + from starlette.requests import Request + + from app.api.v1.endpoint_modules import static_maps, thumbnails + + module = thumbnails if kind == "thumbnail" else static_maps + endpoint = module.get_thumbnail if kind == "thumbnail" else module.get_static_map_asset + service_name = "ImageService" if kind == "thumbnail" else "StaticMapService" + with ( + patch.object( + module, + "get_visual_asset_public_url", + return_value="https://assets.example.org/assets/hash", + ), + patch.object(module, service_name) as service, + ): + response = await endpoint("a" * 64, Request({"type": "http", "headers": []})) + assert response.status_code == 302 + assert response.headers["location"] == "https://assets.example.org/assets/hash" + service.assert_not_called() diff --git a/backend/tools/maplibre-static-prototype/.gitignore b/backend/tools/maplibre-static-prototype/.gitignore new file mode 100644 index 0000000..e0e6e61 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +output/ diff --git a/backend/tools/maplibre-static-prototype/Dockerfile.linux b/backend/tools/maplibre-static-prototype/Dockerfile.linux new file mode 100644 index 0000000..dc53b51 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/Dockerfile.linux @@ -0,0 +1,15 @@ +# Build the unmodified repository Dockerfile as ogm-native-api-base:prototype first. +FROM node:22-bookworm-slim AS node +FROM ogm-native-api-base:prototype +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && apt-get update && apt-get install -y --no-install-recommends \ + libegl1 libgles2 libopengl0 libgl1-mesa-dri xvfb xauth mesa-utils \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/map-prototype +COPY backend/tools/maplibre-static-prototype/package*.json ./ +RUN npm ci +COPY backend/tools/maplibre-static-prototype/*.mjs backend/tools/maplibre-static-prototype/*.json ./ +ENV LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe +CMD ["xvfb-run", "-a", "sh", "-c", "glxinfo -B && mkdir -p output && npm run demo"] diff --git a/backend/tools/maplibre-static-prototype/Dockerfile.worker b/backend/tools/maplibre-static-prototype/Dockerfile.worker new file mode 100644 index 0000000..99c92fd --- /dev/null +++ b/backend/tools/maplibre-static-prototype/Dockerfile.worker @@ -0,0 +1,16 @@ +FROM node:22-bookworm-slim AS node +FROM ubuntu:24.04 +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates fonts-dejavu-core libegl1 libgles2 libopengl0 libgl1-mesa-dri libglx-mesa0 libcurl4t64 \ + libjpeg-turbo8 libuv1t64 libicu74 libpng16-16t64 libwebp7 xvfb xauth mesa-utils \ + && rm -rf /var/lib/apt/lists/* +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm +WORKDIR /opt/map-prototype +COPY backend/tools/maplibre-static-prototype/package*.json ./ +RUN npm ci +COPY backend/tools/maplibre-static-prototype/*.mjs backend/tools/maplibre-static-prototype/*.json ./ +ENV LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe DISPLAY=:99 +# Explicit display startup avoids xvfb-run's signal wait in this container runtime. +CMD ["sh", "-c", "rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; glxinfo -B && mkdir -p output && npm run demo"] diff --git a/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md b/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md new file mode 100644 index 0000000..96913aa --- /dev/null +++ b/backend/tools/maplibre-static-prototype/LINUX-RESULTS.md @@ -0,0 +1,96 @@ +# Linux container verification — 2026-10-23 + +## Result + +**Not compatible as a drop-in renderer in the current API image.** +The repository Dockerfile uses `python:3.11-slim-bookworm` (Debian 12), not Ubuntu. +The MapLibre Native 6.4.1 Linux ARM64 binary fails to load there: it needs +GLIBC_2.38, GLIBCXX_3.4.31/32, libjpeg.so.8, and ICU 74. Bookworm supplies older +ABIs. Adding Mesa/Xvfb does not resolve this. Do not transplant Ubuntu system +libraries into the API image. + +**A separate Ubuntu 24.04 render worker works without a GPU.** +`Dockerfile.worker` runs the same renderer with Mesa llvmpipe and a virtual X +display. `glxinfo -B` reported `Accelerated: no`. No GPU devices were passed. +Installed `fonts-dejavu-core` after visual QA caught missing attribution glyphs; +rerendered and visually verified both corrected images. +Test limits: 2 CPUs, 1 GiB memory; architecture: Linux ARM64 on Docker Desktop. +Production server CPU architecture remains unknown. A subsequent amd64 test +passed under Docker emulation; see ../../../docs/native-static-maps.md (from the +repository root: docs/native-static-maps.md) for the Celery integration results. + +| Example | Size | Time | Requests | Linux PNG SHA-256 | +| --- | --- | --- | --- | --- | +| Indiana campus | 640×320 | 1.488 s | 8 | 93d6b7b452665297dfdeaa046c345b82855b042f1cbcba5ad8d4ea734f6f50af | +| Resource footprint | 800×600 | 0.949 s | 22 | 51eda75c73db69272e76c5e96b9e0ce95cf67ce7d8eda52b5eb39815e2017b5a | + +These are single-run observations, not load-test benchmarks. The images were +rendered inside Linux, not copied from the earlier macOS demonstration. + +## Existing asset system: passed + +Built the current repository Dockerfile without modifying it. Used that image +with disposable PostgreSQL 16 and Redis 7.4.6 containers. The test harness mounts +the **unchanged production static-map router** in a minimal FastAPI application; +it does not exercise full application middleware, authentication, Elasticsearch, +or Celery dispatch. + +For both PNGs: + +- Persisted bytes in existing `generated_visual_assets` storage and Redis. +- GET `/api/v1/static-map-assets/{sha256}` returned HTTP 200, `image/png`, and + byte-for-byte identical SHA-256. +- Cache-Control was `public, max-age=31536000, immutable`. +- Deleted the Redis asset key; GET recovered the PNG from PostgreSQL and + repopulated Redis with identical bytes. +- Conditional GET with the returned ETag returned HTTP 304. + +The resource also passed `StaticMapService.materialize_asset_sync` and persisted +its existing resource/variant/signature link. Campus IDs are not resource IDs: +the links table has a resource foreign key. The campus test therefore uses the +existing durable content-addressed store and Redis asset methods directly, with +no fabricated resource row. Campus alias integration is a remaining production +change; this test does not claim that campus resource links already work. + +## Reproduce + +From the repository root (requires Docker; isolated test names must be free): + +```sh +docker build -t ogm-native-api-base:prototype . +docker build -f backend/tools/maplibre-static-prototype/Dockerfile.linux -t ogm-native-linux:prototype . +# This preflight demonstrates the incompatibility without waiting on Xvfb: +docker run --rm ogm-native-linux:prototype node -e 'require("@maplibre/maplibre-gl-native")' + +docker build -f backend/tools/maplibre-static-prototype/Dockerfile.worker -t ogm-native-worker:prototype . +docker run --name ogm-native-worker --memory=1g --cpus=2 ogm-native-worker:prototype +mkdir -p /tmp/ogm-native-linux-output +docker cp ogm-native-worker:/opt/map-prototype/output/. /tmp/ogm-native-linux-output/ + +docker network create ogm-native-test +docker run -d --name ogm-native-postgres --network ogm-native-test -e POSTGRES_PASSWORD=prototype -e POSTGRES_DB=native_render_test postgres:16 +docker run -d --name ogm-native-redis --network ogm-native-test redis:7.4.6-alpine +# Wait for pg_isready before starting the API harness. +docker exec ogm-native-postgres pg_isready -U postgres -d native_render_test +docker run -d --name ogm-native-assets --network ogm-native-test -p 127.0.0.1:3006:8000 -e DATABASE_URL=postgresql+asyncpg://postgres:prototype@ogm-native-postgres:5432/native_render_test -e REDIS_HOST=ogm-native-redis -e REDIS_PASSWORD= -e VISUAL_ASSET_DURABLE_STORE=database -e PYTHONPATH=/app/backend:/opt/map-prototype -w /app/backend ogm-native-api-base:prototype sleep infinity +docker exec ogm-native-assets mkdir -p /opt/map-prototype/output +docker cp /tmp/ogm-native-linux-output/. ogm-native-assets:/opt/map-prototype/output/ +docker cp backend/tools/maplibre-static-prototype/verify_assets.py ogm-native-assets:/opt/map-prototype/ +docker cp backend/tools/maplibre-static-prototype/check_http.py ogm-native-assets:/opt/map-prototype/ +docker exec -d ogm-native-assets sh -c 'uvicorn verify_assets:app --host 0.0.0.0 --port 8000 > /tmp/asset-server.log 2>&1' +# Once Uvicorn is ready: +docker exec ogm-native-assets python /opt/map-prototype/check_http.py +``` + +Local review remains available on port 3006. To remove only this experiment: + +```sh +docker rm -f -v ogm-native-assets ogm-native-postgres ogm-native-redis ogm-native-worker ogm-native-worker-fonts ogm-native-render +docker network rm ogm-native-test +``` + +No production Dockerfile, API route, cache schema, or Celery task was changed. +Recommendation: keep rendering in a dedicated compatible worker image if this +approach proceeds. Before adoption, verify deployment CPU architecture, workload +concurrency/memory, provider authorization for bulk generation, and campus alias +handling. A whole-API OS upgrade is not justified by this prototype alone. diff --git a/backend/tools/maplibre-static-prototype/README.md b/backend/tools/maplibre-static-prototype/README.md new file mode 100644 index 0000000..eaf938d --- /dev/null +++ b/backend/tools/maplibre-static-prototype/README.md @@ -0,0 +1,70 @@ +For the opt-in API/Celery integration and amd64 results, see +[deployment guidance](../../../docs/native-static-maps.md). + +# MapLibre Native static-map prototype + +Isolated renderer experiment; existing API routes, Celery tasks, Redis, durable +visual asset tables, and production static-map variants are unchanged. +Requires Node 22 and the native runtime supported by @maplibre/maplibre-gl-native. +Verified on macOS and in a separate Ubuntu 24.04 ARM64 software-rendering worker. +The current Debian API image cannot load the native binary. See +[Linux verification results](LINUX-RESULTS.md) for evidence and asset round-trip tests. + +```sh +cd backend/tools/maplibre-static-prototype +npm ci +npm test +mkdir -p output +npm run demo +python3 -m http.server 3005 --bind 127.0.0.1 --directory output +``` + +Open http://127.0.0.1:3005/. Copy `preview.html` to `output/index.html` if +recreating the preview from a clean checkout. Generated files are ignored. + +## Examples + +- `campus.json`: Indiana University, Discovery's configured center and zoom, + 640×320. Converts legacy 256-pixel zoom 15 to MapLibre zoom 14. +- `resource.json`: real API record `0efeae5d-6e6c-513c-ab45-01c5fc0cb16c`, + “Madison Structure.” Its ENVELOPE geometry is normalized to a GeoJSON polygon. + Output is 800×600 with a 48px fit margin. This is the metadata footprint, + not a rendering of the underlying resource's scientific data. + +OpenFreeMap Liberty is used for this bounded experiment, with a unique user +agent, at most four concurrent requests, and per-image request/byte budgets. +There is no persistent tile cache. Do not point this at Maptoolkit's free service: +its community terms exclude backend-only processing. Production bulk generation +needs institution-owned tiles or a provider agreement appropriate to the workload. +The attribution footer is currently specific to OpenFreeMap; changing providers +requires changing credits too. The preview supplies clickable credit links. + +Each output has a JSON manifest containing the complete input, camera, render +version, style/input signature, image SHA-256, request count, bytes, and timing. +Remote errors prevent writing a new PNG. A prior successful PNG is left intact. +The style signature includes the resolved style JSON, but not remote tile, glyph, +or sprite contents; use a provider dataset revision before production caching. + +## Existing asset-system handoff + +`render.mjs` produces PNG bytes; it does not introduce a storage service. A future +Celery adapter can invoke it in a bounded subprocess, validate successful exit +and PNG dimensions/hash, then pass the bytes to the existing +`StaticMapService.materialize_asset_sync(resource_id, variant=..., map_bytes=..., +source_signature=...)`. Use a NEW versioned variant, never the existing CARTO +variants, and the manifest's sourceSignature. This preserves Redis, durable +storage, aliases, and `/static-map-assets/{hash}` delivery. The demo command does not write to databases. The separate Linux integration +harness writes only to disposable test PostgreSQL and Redis containers. + +## Before production + +- Verify Linux image/runtime, bounded worker concurrency, cancellation, and RSS. +- Add provider-specific attribution, provenance/revision, fallback policy, and + request allowlisting before accepting untrusted style URLs. +- Exercise true polygon holes/multipolygons, points/lines, invalid geometry, + dateline normalization, retina output, and missing geometry. Crossing/polar + bounds deliberately fail in this prototype rather than silently misframe. +- Validate sprite/glyph/tile failure behavior and publish assets atomically. +- Keep final images/credits readable at the intended campus tile display size. +- Wire a versioned production variant and regenerate through existing jobs only + after visual parity and provider suitability are established. diff --git a/backend/tools/maplibre-static-prototype/campus.json b/backend/tools/maplibre-static-prototype/campus.json new file mode 100644 index 0000000..7ed325e --- /dev/null +++ b/backend/tools/maplibre-static-prototype/campus.json @@ -0,0 +1,12 @@ +{ + "style": "https://tiles.openfreemap.org/styles/liberty", + "id": "institution:indiana-university", + "title": "Indiana University campus", + "width": 640, + "height": 320, + "center": [ + -86.5235, + 39.1702 + ], + "leafletZoom": 15 +} \ No newline at end of file diff --git a/backend/tools/maplibre-static-prototype/check_celery.py b/backend/tools/maplibre-static-prototype/check_celery.py new file mode 100644 index 0000000..624d618 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/check_celery.py @@ -0,0 +1,31 @@ +import hashlib +import json +import urllib.request +from pathlib import Path + +from sqlalchemy import update +from app.tasks.worker import celery_app +from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_durable_visual_asset +from db.models import resources +from db.session import sync_engine + +assert sync_engine.url.database == 'native_render_test' +record = json.loads(Path('/opt/map-prototype/resource.json').read_text()) +with sync_engine.begin() as conn: + conn.execute(update(resources).where(resources.c.id == record['id']).values(locn_geometry=record['sourceGeometry'])) +task = celery_app.send_task('generate_static_map', args=[record['id']]) +result = task.get(timeout=100) +assert result['success'], result +service = StaticMapService() +sig = service.geometry_signature(record['sourceGeometry']) +digest = service.get_asset_hash_sync(record['id'], variant=service.geometry_variant(), source_signature=sig) +assert digest +url = 'http://127.0.0.1:8000/api/v1/static-map-assets/' + digest +with urllib.request.urlopen(url) as response: + data = response.read() + assert hashlib.sha256(data).hexdigest() == digest + assert get_durable_visual_asset(digest) == (data,'image/png') + assert response.headers['Content-Type'] == 'image/png' +print(json.dumps({'task': task.id, 'result': result, 'asset': digest, 'bytes': len(data)})) +Path('/tmp/celery-resource.png').write_bytes(data) diff --git a/backend/tools/maplibre-static-prototype/check_http.py b/backend/tools/maplibre-static-prototype/check_http.py new file mode 100644 index 0000000..2b30cc9 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/check_http.py @@ -0,0 +1,29 @@ +"""Check real HTTP delivery, ETags, and durable-store recovery after cache eviction.""" +import hashlib +import json +import urllib.request +import urllib.error +from pathlib import Path + +from app.services.static_map_service import StaticMapService + +service = StaticMapService() +for asset in json.loads(Path('/opt/map-prototype/output/assets.json').read_text()): + url = 'http://127.0.0.1:8000' + asset['url'] + for mode in ('redis', 'durable-recovery'): + if mode == 'durable-recovery': + service.map_cache.delete(service._asset_key(asset['sha256'])) + with urllib.request.urlopen(url) as response: + assert response.status == 200 + assert response.headers['Content-Type'] == 'image/png' + assert hashlib.sha256(response.read()).hexdigest() == asset['sha256'] + etag = response.headers['ETag'] + print(asset['name'], mode, response.status, response.headers['Cache-Control'], etag) + assert service.map_cache.exists(service._asset_key(asset['sha256'])) + try: + urllib.request.urlopen(urllib.request.Request(url, headers={'If-None-Match': etag})) + except urllib.error.HTTPError as response: + assert response.code == 304 + print(asset['name'], 'conditional GET', response.code) + else: + raise AssertionError('Expected HTTP 304') diff --git a/backend/tools/maplibre-static-prototype/integrated_app.py b/backend/tools/maplibre-static-prototype/integrated_app.py new file mode 100644 index 0000000..6d239b3 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/integrated_app.py @@ -0,0 +1,42 @@ +"""Local-only preview harness: hydrate missing public records into the isolated DB.""" +import asyncio +import json +import urllib.request +from urllib.parse import quote + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.dialects.postgresql import insert +from app.api.v1.endpoint_modules import static_maps +from db.models import resources +from db.session import sync_engine + +assert sync_engine.url.database == "native_render_test", "Preview harness requires isolated database" +original_fetch = static_maps._fetch_resource_dict + + +def hydrate_record(resource_id): + url = "https://ogm.geo4lib.app/api/v1/resources/" + quote(resource_id, safe="") + with urllib.request.urlopen(url, timeout=15) as response: + record = json.load(response)["data"] + ogm = record["attributes"]["ogm"] + values = {key: ogm[key] for key in ("dct_title_s", "locn_geometry", "dcat_bbox") if key in ogm} + with sync_engine.begin() as connection: + connection.execute(insert(resources).values(id=resource_id, **values).on_conflict_do_nothing()) + + +async def fetch_preview_record(resource_id): + record = await original_fetch(resource_id) + if record is None: + try: + await asyncio.to_thread(hydrate_record, resource_id) + except Exception: + return None + record = await original_fetch(resource_id) + return record + + +static_maps._fetch_resource_dict = fetch_preview_record +app = FastAPI() +app.add_middleware(CORSMiddleware, allow_origin_regex=r"http://(127\.0\.0\.1|localhost):[0-9]+", allow_methods=["GET"], expose_headers=["Retry-After"]) +app.include_router(static_maps.router, prefix="/api/v1") diff --git a/backend/tools/maplibre-static-prototype/package-lock.json b/backend/tools/maplibre-static-prototype/package-lock.json new file mode 100644 index 0000000..69e3def --- /dev/null +++ b/backend/tools/maplibre-static-prototype/package-lock.json @@ -0,0 +1,3058 @@ +{ + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "dependencies": { + "@maplibre/maplibre-gl-native": "6.4.1", + "sharp": "0.35.5" + } + }, + "node_modules/@acalcutt/node-pre-gyp": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@acalcutt/node-pre-gyp/-/node-pre-gyp-2.0.4.tgz", + "integrity": "sha512-oPSZBuqSh1AWuPMsQKMtlcb4hisU4DWtuIo8OlQtMH4F7k1w+gd1Be4dTTZdh8qoG8LfmauTBJraU8DWxub0RA==", + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.5.7" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@acalcutt/node-pre-gyp-github": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@acalcutt/node-pre-gyp-github/-/node-pre-gyp-github-2.0.2.tgz", + "integrity": "sha512-XESr6F1llJ7sU87vti5noR6Xnq00g7wo5Jr4GnAJ42spO69atXCjCJb9MYLYMQQpXn2Ts/T1uYaG+gWMb/69ug==", + "license": "MIT", + "dependencies": { + "@octokit/rest": "22.0.1", + "commander": "14.0.3", + "semver": "^7.7.4" + }, + "bin": { + "node-pre-gyp-github": "bin/node-pre-gyp-github.js" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-native": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-native/-/maplibre-gl-native-6.4.1.tgz", + "integrity": "sha512-eiJnCOIea2PrGmxZnocP61kEJO4IAhNS/yvkdNh+PR50qdR4khAGU/dBA0IV+uXEQCu33Fux//J1wOQ5srYVGQ==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "dependencies": { + "@acalcutt/node-pre-gyp": "2.0.4", + "@acalcutt/node-pre-gyp-github": "2.0.2", + "minimatch": "^10.2.2", + "npm-run-all": "4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^18.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/backend/tools/maplibre-static-prototype/package.json b/backend/tools/maplibre-static-prototype/package.json new file mode 100644 index 0000000..99d9b68 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/package.json @@ -0,0 +1,14 @@ +{ + "name": "ogm-maplibre-static-prototype", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "demo": "node render.mjs campus.json output/campus.png && node render.mjs resource.json output/resource.png" + }, + "dependencies": { + "@maplibre/maplibre-gl-native": "6.4.1", + "sharp": "0.35.5" + } +} diff --git a/backend/tools/maplibre-static-prototype/preview.html b/backend/tools/maplibre-static-prototype/preview.html new file mode 100644 index 0000000..319da01 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/preview.html @@ -0,0 +1,6 @@ +OGM static map prototype + +

MapLibre Native static maps

Two locally generated PNG assets using OpenFreeMap Liberty. No browser map or WebGL context is needed to display them.

+

Indiana University campus

Campus background showing Indiana University in Bloomington
640 × 320 · existing campus framing · background ready for a school logo overlay
Render manifest
+

Resource geometry: Madison Structure

Blue rectangular metadata footprint over southern Saskatchewan
800 × 600 · real resource envelope · blue geometry overlay with padded fit
Render manifest · Source record
+

Maps: OpenFreeMap · © OpenMapTiles · © OpenStreetMap contributors

diff --git a/backend/tools/maplibre-static-prototype/prime-assets.py b/backend/tools/maplibre-static-prototype/prime-assets.py new file mode 100644 index 0000000..ff67ba2 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/prime-assets.py @@ -0,0 +1,36 @@ +"""Warm native asset URLs before deploy: python prime-assets.py urls.json. + +urls.json is an array of full campus or resource thumbnail URLs. Uses only +public GET requests; no direct database access or tile caching. +""" +import json +import sys +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor + + +def prime(url): + started = time.monotonic() + while time.monotonic() - started < 180: + try: + with urllib.request.urlopen(url, timeout=20) as response: + body = response.read() + if not body.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("Expected PNG: " + url) + print(json.dumps({"url": url, "bytes": len(body), "seconds": round(time.monotonic() - started, 3)}), flush=True) + return + except urllib.error.HTTPError as error: + if error.code not in (429, 503): + raise + delay = error.headers.get("Retry-After", "2") + time.sleep(min(10, max(1, int(delay) if delay.isdigit() else 2))) + raise TimeoutError(url) + + +if __name__ == "__main__": + with open(sys.argv[1]) as source: + urls = json.load(source) + with ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(prime, dict.fromkeys(urls))) diff --git a/backend/tools/maplibre-static-prototype/render.mjs b/backend/tools/maplibre-static-prototype/render.mjs new file mode 100644 index 0000000..31e0a6f --- /dev/null +++ b/backend/tools/maplibre-static-prototype/render.mjs @@ -0,0 +1,198 @@ +import mbgl from "@maplibre/maplibre-gl-native"; +import sharp from "sharp"; +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +export function fitBounds( + [west, south, east, north], + width, + height, + padding = 48, +) { + if ( + ![west, south, east, north].every(Number.isFinite) || + west >= east || + south >= north || + south < -85.05112878 || + north > 85.05112878 + ) + throw new Error("Invalid or crossing bounds: normalize before rendering"); + const y = (lat) => + (1 - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)) / Math.PI) / 2; + const middleY = (y(south) + y(north)) / 2; + return { + center: [ + (west + east) / 2, + (Math.atan(Math.sinh(Math.PI * (1 - 2 * middleY))) * 180) / Math.PI, + ], + zoom: Math.min( + 20, + Math.log2((width - 2 * padding) / ((512 * (east - west)) / 360)), + Math.log2((height - 2 * padding) / (512 * (y(south) - y(north)))), + ), + }; +} + +export async function render(spec, output) { + const started = Date.now(); + const errors = []; + let requests = 0, + bytes = 0, + active = 0; + const queue = []; + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error("Render exceeded 60 seconds")), + 60000, + ); + const download = async (url) => { + const response = await fetch(url, { + headers: { "User-Agent": "OpenGeoMetadata-API-StaticMaps/1.0 (https://opengeometadata.org/)" }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`); + const data = Buffer.from(await response.arrayBuffer()); + requests++; + bytes += data.length; + if (requests > 3000 || bytes > 50 * 1024 * 1024) + throw new Error("Prototype download budget exceeded"); + return data; + }; + const pump = () => { + while (active < 4 && queue.length) { + const { req, callback } = queue.shift(); + active++; + download(req.url) + .then( + (data) => callback(null, { data }), + (error) => { + errors.push(error.message); + callback(error); + }, + ) + .finally(() => { + active--; + pump(); + }); + } + }; + let map; + const onMessage = (message) => { + if (message.severity === "ERROR") errors.push(message.text); + }; + mbgl.on("message", onMessage); + try { + const style = JSON.parse(await download(spec.style)); + if (spec.geometry) { + style.sources["ogm-geometry"] = { + type: "geojson", + data: { type: "Feature", properties: {}, geometry: spec.geometry }, + }; + style.layers.push( + { + id: "ogm-fill", + type: "fill", + source: "ogm-geometry", + paint: { "fill-color": "#2563eb", "fill-opacity": 0.06 }, + }, + { + id: "ogm-line", + type: "line", + source: "ogm-geometry", + paint: { + "line-color": "#2563eb", + "line-width": 3, + "line-opacity": 0.85, + }, + }, + ); + } + if (spec.geometry) + style.layers.push({ + id: "ogm-point", + type: "circle", + source: "ogm-geometry", + filter: ["==", "$type", "Point"], + paint: { + "circle-radius": 5, + "circle-color": "#2563eb", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 2, + }, + }); + map = new mbgl.Map({ + ratio: 1, + request(req, callback) { + queue.push({ req, callback }); + pump(); + }, + }); + map.load(style); + const camera = spec.bounds + ? fitBounds(spec.bounds, spec.width, spec.height) + : { center: spec.center, zoom: spec.leafletZoom - 1 }; + const pixels = await new Promise((resolve, reject) => + map.render( + { ...camera, width: spec.width, height: spec.height }, + (error, data) => (error ? reject(error) : resolve(data)), + ), + ); + if (errors.length) throw new Error(errors.join("\n")); + // Native rendering has no DOM attribution control; burn credits into the asset. + const compact = spec.width < 500; + const footer = Buffer.from( + `${compact + ? `OpenFreeMap · © OpenMapTiles© OpenStreetMap contributors` + : `OpenFreeMap · © OpenMapTiles · © OpenStreetMap contributors`}`, + ); + const png = await sharp(pixels, { + raw: { width: spec.width, height: spec.height, channels: 4 }, + }) + .composite([{ input: footer, gravity: "south" }]) + .png() + .toBuffer(); + await writeFile(output, png); + const manifest = { + renderer: "maplibre-native-6.4.1-prototype-v1", + spec, + camera, + requests, + downloadedBytes: bytes, + durationMs: Date.now() - started, + sha256: createHash("sha256").update(png).digest("hex"), + sourceSignature: createHash("sha256") + .update( + JSON.stringify({ + renderer: "maplibre-native-6.4.1-prototype-v1", + spec, + style, + }), + ) + .digest("hex"), + }; + await writeFile(`${output}.json`, JSON.stringify(manifest, null, 2)); + return manifest; + } finally { + clearTimeout(timer); + controller.abort(); + map?.release(); + mbgl.off("message", onMessage); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const [input, output] = process.argv.slice(2); + if (!input || !output) + throw new Error("Usage: node render.mjs spec.json output.png"); + console.log( + JSON.stringify( + await render(JSON.parse(await readFile(input, "utf8")), output), + null, + 2, + ), + ); +} diff --git a/backend/tools/maplibre-static-prototype/render.test.mjs b/backend/tools/maplibre-static-prototype/render.test.mjs new file mode 100644 index 0000000..319fd8a --- /dev/null +++ b/backend/tools/maplibre-static-prototype/render.test.mjs @@ -0,0 +1,13 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fitBounds } from './render.mjs'; +test('Mercator bounds fit respects padding and centers projected latitude', () => { + const result = fitBounds([-10, -10, 10, 10], 800, 600); + assert.ok(Math.abs(result.center[0]) < 1e-10); + assert.ok(Math.abs(result.center[1]) < 1e-10); + assert.ok(result.zoom > 4 && result.zoom < 5); +}); +test('invalid and dateline-crossing bounds fail explicitly', () => { + assert.throws(() => fitBounds([170, -10, -170, 10], 800, 600)); + assert.throws(() => fitBounds([-10, -90, 10, 90], 800, 600)); +}); diff --git a/backend/tools/maplibre-static-prototype/resource.json b/backend/tools/maplibre-static-prototype/resource.json new file mode 100644 index 0000000..c1e3a11 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/resource.json @@ -0,0 +1,42 @@ +{ + "style": "https://tiles.openfreemap.org/styles/liberty", + "id": "0efeae5d-6e6c-513c-ab45-01c5fc0cb16c", + "title": "Madison Structure", + "sourceUrl": "https://ogm.geo4lib.app/api/v1/resources/0efeae5d-6e6c-513c-ab45-01c5fc0cb16c", + "sourceGeometry": "ENVELOPE(-109.9876, -101.1135, 52.1614, 48.8912)", + "width": 800, + "height": 600, + "bounds": [ + -109.9876, + 48.8912, + -101.1135, + 52.1614 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -109.9876, + 48.8912 + ], + [ + -101.1135, + 48.8912 + ], + [ + -101.1135, + 52.1614 + ], + [ + -109.9876, + 52.1614 + ], + [ + -109.9876, + 48.8912 + ] + ] + ] + } +} \ No newline at end of file diff --git a/backend/tools/maplibre-static-prototype/server.mjs b/backend/tools/maplibre-static-prototype/server.mjs new file mode 100644 index 0000000..7fdb3b5 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/server.mjs @@ -0,0 +1,122 @@ +// Private service. Do not expose this port publicly. Celery owns scheduling/retries. +import http from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +const style = + process.env.STATIC_MAP_STYLE_URL || + "https://tiles.openfreemap.org/styles/liberty"; +if (new URL(style).hostname !== "tiles.openfreemap.org") + throw new Error( + "This renderer currently supports OpenFreeMap styles/attribution only", + ); +let busy = false; +http + .createServer(async (req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.end("ok"); + return; + } + if (req.method !== "POST" || req.url !== "/render") { + res.writeHead(404).end(); + return; + } + if (busy) { + res.writeHead(503, { "Retry-After": "3" }).end("Renderer busy"); + return; + } + busy = true; + let dir; + try { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 2 * 1024 * 1024) throw new Error("Request too large"); + chunks.push(chunk); + } + const input = JSON.parse(Buffer.concat(chunks)); + if ( + ![input.width, input.height].every( + (n) => Number.isInteger(n) && n >= 64 && n <= 1600, + ) + ) + throw new Error("Invalid dimensions"); + if ( + !input.bounds && + (!Array.isArray(input.center) || + input.center.length !== 2 || + !input.center.every(Number.isFinite) || + Math.abs(input.center[0]) > 180 || + Math.abs(input.center[1]) > 85 || + !Number.isFinite(input.leafletZoom) || + input.leafletZoom < 1 || + input.leafletZoom > 20) + ) + throw new Error("Invalid camera"); + if ( + input.geometry && + ![ + "Polygon", + "MultiPolygon", + "LineString", + "MultiLineString", + "Point", + "MultiPoint", + "GeometryCollection", + ].includes(input.geometry.type) + ) + throw new Error("Invalid geometry"); + // Only accept data/camera; clients cannot choose remote style/source URLs. + const spec = { + style, + width: input.width, + height: input.height, + bounds: input.bounds, + center: input.center, + leafletZoom: input.leafletZoom, + geometry: input.geometry, + }; + dir = await mkdtemp(join(tmpdir(), "ogm-render-")); + await writeFile(join(dir, "spec.json"), JSON.stringify(spec)); + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["render.mjs", join(dir, "spec.json"), join(dir, "map.png")], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + let error = ""; + child.stderr.on("data", (b) => { + error = (error + b).slice(-4000); + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 65000); + child.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + child.on("close", (code) => { + clearTimeout(timer); + code === 0 + ? resolve() + : reject(new Error(error || `Renderer exit ${code}`)); + }); + }); + const png = await readFile(join(dir, "map.png")); + res + .writeHead(200, { + "Content-Type": "image/png", + "Cache-Control": "no-store", + }) + .end(png); + } catch (error) { + console.error(error.message); + res + .writeHead(502, { "Cache-Control": "no-store" }) + .end("Map rendering failed"); + } finally { + if (dir) await rm(dir, { recursive: true, force: true }); + busy = false; + } + }) + .listen(8080, "0.0.0.0"); diff --git a/backend/tools/maplibre-static-prototype/verify_assets.py b/backend/tools/maplibre-static-prototype/verify_assets.py new file mode 100644 index 0000000..973bf36 --- /dev/null +++ b/backend/tools/maplibre-static-prototype/verify_assets.py @@ -0,0 +1,47 @@ +"""Isolated integration harness: real API router, Redis and PostgreSQL, no mocks.""" +import hashlib +import json +from pathlib import Path + +from fastapi import FastAPI +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert + +from app.api.v1.endpoint_modules.static_maps import router +from app.services.static_map_service import StaticMapService +from app.services.visual_asset_cache import get_durable_visual_asset, store_durable_visual_asset +from db.models import resources, generated_visual_assets, generated_visual_asset_links +from db.session import sync_engine + +# Test-only database. Never run this against a production DATABASE_URL. +assert sync_engine.url.database == 'native_render_test' +for table in (resources, generated_visual_assets, generated_visual_asset_links): + table.create(sync_engine, checkfirst=True) + +service = StaticMapService() +results = [] +for name in ('campus', 'resource'): + path = Path('/opt/map-prototype/output') / f'{name}.png' + body = path.read_bytes() + manifest = json.loads(path.with_suffix('.png.json').read_text()) + digest = hashlib.sha256(body).hexdigest() + assert digest == manifest['sha256'] + variant = 'maplibre_native_prototype_v1' + if name == 'resource': + with sync_engine.begin() as connection: + connection.execute(insert(resources).values(id=manifest['spec']['id'], dct_title_s=manifest['spec']['title']).on_conflict_do_nothing(index_elements=['id'])) + assert service.materialize_asset_sync(manifest['spec']['id'], variant=variant, map_bytes=body, source_signature=manifest['sourceSignature']) == digest + with sync_engine.connect() as connection: + assert connection.execute(select(generated_visual_asset_links).where(generated_visual_asset_links.c.asset_hash == digest)).first() + else: + # Campus IDs are not resource IDs: avoid violating the resource-link FK. + # Use the existing content-addressed asset store, not a fabricated resource. + assert store_durable_visual_asset(digest, asset_kind=variant, content_type='image/png', body=body) + service.cache_asset_sync(digest, body) + assert get_durable_visual_asset(digest) == (body, 'image/png') + assert service.map_cache.get(service._asset_key(digest)) == body + results.append({'name': name, 'sha256': digest, 'bytes': len(body), 'url': f'/api/v1/static-map-assets/{digest}'}) +Path('/opt/map-prototype/output/assets.json').write_text(json.dumps(results, indent=2)) + +app = FastAPI() +app.include_router(router, prefix='/api/v1') diff --git a/backend/uv.lock b/backend/uv.lock index 09a484e..9cafe3e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -281,6 +281,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" }, ] +[[package]] +name = "boto3" +version = "1.43.119" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/1b/97f8ec6ce00ae49455797b8af9fde522624ecf80bc73c937596b558b1853/boto3-1.43.119.tar.gz", hash = "sha256:e12c3b1d81713e14bd6947b7412d9d0ea49b320642d795ded1d64d6483ca6f49", size = 112659, upload-time = "2026-10-26T19:25:58.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d7/6c82dff652082a4bab907b7a5833933a362f12400f14bd7a9935a330145e/boto3-1.43.119-py3-none-any.whl", hash = "sha256:d1718edae48c3241d289c0ece8a515399a7242c406e35328b9685c4d24039186", size = 140045, upload-time = "2026-10-26T19:25:57.128Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.119" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/ab/8e6d1d97c086c127f725cdeffae3b44d92ca3ca40741fb90f245f11887d5/botocore-1.43.119.tar.gz", hash = "sha256:ed11934be6e6d398ac21b7fe03778c9584368eeff8ba5371581f19bb47db0b6a", size = 16457548, upload-time = "2026-10-26T19:25:52.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/54/04e5950c7f2d22d78f22db00ca119b257b7cd2024328b6116ebaeb9c14eb/botocore-1.43.119-py3-none-any.whl", hash = "sha256:6026aec97914a4fa116915fd3b05af899912eff51b93fb8dff95aa228a3e761b", size = 16154402, upload-time = "2026-10-26T19:25:48.301Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -1119,6 +1147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jsonschema" version = "4.24.0" @@ -1651,6 +1688,7 @@ dependencies = [ { name = "appsignal" }, { name = "async-timeout" }, { name = "asyncpg" }, + { name = "boto3" }, { name = "celery" }, { name = "click" }, { name = "databases" }, @@ -1725,6 +1763,7 @@ requires-dist = [ { name = "appsignal", specifier = ">=1.0.0" }, { name = "async-timeout", specifier = "==5.0.1" }, { name = "asyncpg", specifier = "==0.30.0" }, + { name = "boto3", specifier = "==1.43.119" }, { name = "celery", specifier = ">=5.3.0" }, { name = "click", specifier = "==8.1.7" }, { name = "databases", specifier = "==0.9.0" }, @@ -3064,6 +3103,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/fc/be2251ac7ee75e725027b107ab14d161d829aaac22362f75eaabc859bfba/s2sphere-0.2.5-py2.py3-none-any.whl", hash = "sha256:d2340c9cf458ddc9a89afd1d8048a4195ce6fa6b0095ab900d4be5271e537401", size = 23540, upload-time = "2017-11-16T17:47:48.139Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + [[package]] name = "shapely" version = "2.1.1" diff --git a/config/deploy.yml b/config/deploy.yml index f741a48..37a8954 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -53,8 +53,17 @@ env: GAZETTEER_CACHE_TTL: "3600" RESOURCE_REPRESENTATION_DURABLE_STORE: database API_RESPONSE_DURABLE_CACHE_STORE: database - VISUAL_ASSET_DURABLE_STORE: database - VISUAL_ASSET_CACHE_TTL_SECONDS: "0" + VISUAL_ASSET_DURABLE_STORE: s3 + VISUAL_ASSET_CACHE_TTL_SECONDS: "86400" + VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED: "false" + VISUAL_ASSET_S3_RETAIN_DATABASE_BODY: "true" + VISUAL_ASSET_S3_ENDPOINT: https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com + VISUAL_ASSET_S3_REGION: auto + VISUAL_ASSET_S3_BUCKET: ogm + VISUAL_ASSET_S3_PREFIX: assets + VISUAL_ASSET_PUBLIC_BASE_URL: https://assets.geo4lib.app + STATIC_MAP_RENDERER_URL: http://ogm-api-map-renderer:8080 + STATIC_MAP_RENDER_REVISION: openfreemap-liberty-native-v1 ENABLE_FAST_EMBEDDINGS: "false" ENABLE_APPSIGNAL: "false" APP_MODE: production @@ -83,9 +92,23 @@ env: - OPENAI_MODEL - POSTGRES_PASSWORD - OGM_WEBHOOK_SECRET + - VISUAL_ASSET_S3_ACCESS_KEY_ID + - VISUAL_ASSET_S3_SECRET_ACCESS_KEY # ────────────── Accessories (one per Compose service) ────────────── accessories: + map-renderer: + image: ghcr.io/ewlarson/ogm-maplibre-renderer:r2-20261026 + roles: [web] + cmd: sh -c 'rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; exec node server.mjs' + env: + clear: + STATIC_MAP_STYLE_URL: https://tiles.openfreemap.org/styles/liberty + options: + init: true + memory: 1g + cpus: 2 + log-opt: max-size=10m elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:9.0.0 roles: [web] diff --git a/deploy/Dockerfile.visual-assets b/deploy/Dockerfile.visual-assets new file mode 100644 index 0000000..7a94374 --- /dev/null +++ b/deploy/Dockerfile.visual-assets @@ -0,0 +1,9 @@ +# Preserve the exact deployed runtime; layer the reviewed visual-asset source onto it. +FROM ghcr.io/ewlarson/opengeometadata-api@sha256:eeff5bbf7d415903c6f1fdcba02ee97d7303521ab9323687ee834f6d5566ef8b +RUN uv pip install --system --no-cache boto3==1.43.119 botocore==1.43.119 s3transfer==0.19.3 jmespath==1.1.0 +COPY backend/app/ /app/backend/app/ +COPY backend/db/ /app/backend/db/ +COPY backend/scripts/ /app/backend/scripts/ +COPY backend/scripts/ /app/scripts/ +COPY backend/pyproject.toml backend/uv.lock /app/ +COPY backend/pyproject.toml backend/uv.lock /app/backend/ diff --git a/docker-compose.native-maps.yml b/docker-compose.native-maps.yml new file mode 100644 index 0000000..36cc741 --- /dev/null +++ b/docker-compose.native-maps.yml @@ -0,0 +1,29 @@ +# Opt in with: docker compose -f docker-compose.yml -f docker-compose.native-maps.yml up -d --build +# Only the private renderer uses Ubuntu. API/Celery keep their existing image. +services: + api: + environment: + STATIC_MAP_RENDERER_URL: http://map_renderer:8080 + STATIC_MAP_RENDER_REVISION: ${STATIC_MAP_RENDER_REVISION:-openfreemap-liberty-native-v1} + celery_worker: + environment: + STATIC_MAP_RENDERER_URL: http://map_renderer:8080 + STATIC_MAP_RENDER_REVISION: ${STATIC_MAP_RENDER_REVISION:-openfreemap-liberty-native-v1} + map_renderer: + build: + context: . + dockerfile: backend/tools/maplibre-static-prototype/Dockerfile.worker + command: + [ + "sh", + "-c", + "rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; Xvfb :99 -screen 0 1280x1024x24 -ac -nolisten tcp & sleep 2; exec node server.mjs", + ] + environment: + STATIC_MAP_STYLE_URL: ${STATIC_MAP_STYLE_URL:-https://tiles.openfreemap.org/styles/liberty} + init: true + restart: unless-stopped + mem_limit: 1g + cpus: 2 + networks: + - elastic diff --git a/docs/native-static-maps.md b/docs/native-static-maps.md new file mode 100644 index 0000000..b8a9846 --- /dev/null +++ b/docs/native-static-maps.md @@ -0,0 +1,180 @@ +# Optional native static maps + +The API and its Celery worker retain the existing Debian base image. An internal +Ubuntu 24.04 service renders PNGs using MapLibre Native, Xvfb, Mesa llvmpipe, and +DejaVu fonts. No GPU is required. Celery sends render specifications to this +service; only the API owns Redis, PostgreSQL storage, and asset HTTP delivery. + +## Enable + +```sh +docker compose -f docker-compose.yml -f docker-compose.native-maps.yml up -d --build +``` + +The override sets `STATIC_MAP_RENDERER_URL=http://map_renderer:8080` on `api` and +`celery_worker` and adds the private `map_renderer` service. Do not publish its +port externally. It accepts one render at a time, rejects overload with 503, +and terminates a render subprocess after 65 seconds. The API client has bounded +connect/read timeouts. Campus jobs retry failures up to three times through +Celery. A crashed worker cannot retain a WebGL context across jobs: rendering is +performed in a separate process for each image. + +`STATIC_MAP_RENDER_REVISION` defaults to `openfreemap-liberty-native-v1`. Change it +when changing styles, datasets, fonts, or renderer behavior. Both API and Celery +must use the same value. Dimensions and input geometry/camera also participate +in signatures. Native assets use separate variants from legacy CARTO assets. + +`STATIC_MAP_STYLE_URL` defaults to OpenFreeMap Liberty. Currently only +`tiles.openfreemap.org` styles are supported because the burned-in attribution +and Discovery's companion credit links are OpenFreeMap-specific. A contracted +or institution-owned provider requires adapting the renderer's allowed style +host and attribution together. The Maptoolkit community endpoint must not be +used for backend generation. Establish appropriate provider arrangements before +large collection backfills; do not infer bulk-production authorization from the +small integration test. There is no persistent tile cache. + +## Campus requests and caching + +`GET /api/v1/static-maps/institutions/{slug}/native?lat=39.1702&lon=-86.5235&zoom=15` + +- Requires the native renderer configuration; never falls back to CARTO. +- Validates camera ranges, then checks the versioned image cache. +- Enqueues `generate_campus_static_map` on a miss, deduplicated by Redis lock. +- Waits up to eight seconds for initial generation; otherwise returns 503 with + `Retry-After: 5` and `Cache-Control: no-store`. Discovery retries up to eight + times and keeps the school logo/branded background visible while waiting. +- The task validates the PNG and dimensions before publishing. Failure leaves + prior cached bytes intact. A pending lock expires after five minutes if a + worker is killed; regular retry exhaustion releases it immediately. +- Campus bytes use the existing durable content-addressed asset table plus + Redis. No resource row is fabricated. The camera-to-image cache lives in Redis; + losing that cache regenerates the campus image. The durable hash URL remains + available. No resource-link schema changes are introduced. + +Legacy campus URLs also select native rendering when the integration is enabled. +The separate `/native` URL protects Discovery against older APIs that would +otherwise ignore a new query parameter and serve CARTO. + +## Resource maps + +The existing `generate_static_map`, batch, and collection Celery tasks select the +native renderer through `StaticMapService` when configured. Existing resource +geometry normalization is reused; GeoJSON polygons retain their rings. Rendered +PNGs use the existing resource links, Redis, durable table, and +`/api/v1/static-map-assets/{hash}` endpoint. Existing synchronous compatibility +routes also use the selected renderer; this change does not convert every +legacy resource HTTP cache miss into a queued job. + +Keep the renderer disabled to retain legacy behavior. Disabling it does not +serve native campus URLs from legacy caches. No asset tables are dropped or +rewritten, and the production Dockerfile is unchanged by this integration. + +## Verification (2026-10-23) + +- Ubuntu ARM64 and amd64 both rendered campus and resource examples with + `Accelerated: no`. amd64 ran under Docker CPU emulation on an ARM Mac; this is + binary/runtime compatibility evidence, not a native-server performance test. + Initial amd64 renders took 3.489 s and 3.268 s respectively. +- Real Celery worker in the API's Debian image called the amd64 renderer. + A cold native campus HTTP request queued a job and returned HTTP 200/PNG. +- Existing resource-generation task returned success; the image was retrieved + through the API hash URL and matched its durable stored bytes. +- Earlier asset integration checks verified HTTP 304 and recovery after Redis + eviction. The unchanged asset router is mounted in a minimal FastAPI harness; + production authentication/middleware and a live production deploy were not + part of this test. +- 41 backend static-map tests passed, including configuration signatures, + invalid cameras, failed render rejection, and no legacy fallback. +- All 17 BTAA campus images were generated through Celery and returned HTTP 200. + Browser inspection confirmed the complete grid of school logos over campus + backgrounds and visible provider credits. +- Discovery's two retry/cleanup tests passed, and its subpath production build passed. + +Repeatable renderer Dockerfiles and test harnesses are under +`backend/tools/maplibre-static-prototype/`. `check_celery.py` requires the isolated +`native_render_test` database and an API on port 8000 in its container. It updates +only that test resource and invokes the actual Celery task. Never point a test +harness at production data. + + +## Resource geography migration + +Discovery requests `/api/v1/static-maps/{id}/geometry/native` for generated +resource geography images. This route requires the renderer configuration, +looks up the current geometry and native revision signature, and queues +`generate_native_resource_map` on a cache miss. Redis pending locks deduplicate +concurrent requests; pending responses are 503 with Retry-After and no-store. +The task uses the existing durable asset store and resource links. It does not +replace immutable legacy hashes, which may contain baked-in CARTO error tiles. +Deploy this route and the native worker before the matching Discovery build. +Custom external static-map URLs are preserved by Discovery. + +Local resource-thumbnail verification (2026-10-23): 20 real first-page search +records were seeded into the isolated test database and rendered by the amd64 +Ubuntu service through Celery. All 20 returned PNGs; their durable bytes, +resource links, SHA-256 hashes, and immutable HTTP asset responses matched. +The Hamilton County output was visually inspected. Backend checks: 42 tests +passed. Browser screenshot automation failed, so final in-page visual inspection +remains outstanding. No production records or assets were changed. + +## Loading performance + +Native cache misses return 503 / Retry-After: 2 immediately after enqueueing; +they do not occupy a connection with an eight-second polling loop. Successful +campus and native resource responses allow one hour of browser caching. +`?size=thumbnail` requests a 384×288 resource map with its own geometry/dimension +signature and compact, two-line attribution. The full-size route is unchanged. +Discovery fetches near-viewport images and retries only 503/429 responses without +cache-busting query parameters; CORS must allow the frontend origin. + +Warm deployment-critical URLs with: +`python backend/tools/maplibre-static-prototype/prime-assets.py urls.json` +where the JSON file is an array of full native campus/thumbnail URLs. This uses +the actual API/Celery path and existing asset storage. It does not cache tiles. +Cold render latency remains dependent on provider/network and worker capacity. + +Measured locally on the amd64-emulated worker: existing cold jobs took 2–3s each. +After warming, 17 campus assets with six concurrent HTTP clients had median 14ms, +maximum 56ms. One resource's full PNG was 475,026 bytes; the first compact version +was 131,382 bytes (72% smaller) and served in 5ms warm. These are local API +measurements, not production/network or browser-paint benchmarks. The preview +harness now hydrates missing public records into native_render_test only, so +browsing beyond the original 20 fixtures no longer produces repeated 404s. + + +## Pre-generation and geometry reuse + +With `STATIC_MAP_RENDERER_URL` configured, the existing static-map cache primer +(including bridge-sync cache refresh) and Celery resource generation prepare the +384×288 search thumbnail before the larger maps. This happens even when the +larger maps already exist. Existing records need a backfill; deploying the code +alone does not make every first visit warm. + +From `backend/`, with the normal database, Redis and renderer configuration: + +```sh +python scripts/prime_static_map_cache.py --concurrency 1 --limit 250 +``` + +Remove `--limit` for the full catalog, or pass explicit resource IDs. Start with +one concurrent job for the single-slot renderer. The primer also prepares the +existing full-size variants. For thumbnail-only batches, enqueue the registered +Celery task `pregenerate_native_thumbnails` with one argument: a list of at most +100 resource IDs. Complete each batch before submitting the next to avoid a large +queue. The task retries transient failures and reports prepared and missing counts. + +Matching normalized geometries reuse a durable PNG across resource IDs. Reuse +also checks dimensions, render revision, and overlay kind, so thumbnails cannot +replace full-size maps or plain basemaps. Each resource receives its own link in +the existing asset system; no new storage service or schema is required. A Redis +lock keyed by the render signature prevents simultaneous duplicate rendering. +The native resource endpoint also checks for reusable assets before enqueueing. + +Local verification: an unvisited geology search on page 9 contained 20 records +with 13 distinct geometry signatures. Celery prepared the batch in 32.66 seconds +on the emulated amd64 worker. On the subsequent first browser visit, the first +three thumbnails appeared together at 1.304 seconds after navigation, all HTTP +200 with no generation retries. The earlier unprepared search took 5.630–9.698 +seconds for its first three maps. These are local, unthrottled browser timings, +not a production benchmark. A separate new record reused a matching PNG with +renderer HTTP calls deliberately blocked, confirming durable cross-record reuse. diff --git a/docs/r2-visual-assets.md b/docs/r2-visual-assets.md new file mode 100644 index 0000000..6ee3804 --- /dev/null +++ b/docs/r2-visual-assets.md @@ -0,0 +1,64 @@ +# R2 thumbnails and static maps + +The optional `s3`/`r2` visual store puts generated image bytes in object storage and keeps the existing asset identity, resource links, content type, size, object key, and SHA-256 in PostgreSQL. Existing API URLs remain valid. Once a verified object pointer exists and a public base URL is configured, image endpoints return a 302 to the immutable object URL. Unmigrated images continue to come from PostgreSQL. + +## Infrastructure + +- Bucket: `ogm`, EU jurisdiction, Standard storage. +- S3 endpoint: `https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com` (without `/ogm`). +- Region: `auto`; prefix: `assets`; active public domain: `assets.geo4lib.app` (minimum TLS 1.2). +- Account token: `ogm-visual-assets-production`, Object Read & Write for `ogm` only, until revoked. +- Credentials are in ignored, mode-0600 `.env.r2.local`; excluded from Docker build contexts. Copy to the deployment secret manager, never source control or build arguments. +- Cloudflare nameservers: `adrian.ns.cloudflare.com`, `felipe.ns.cloudflare.com`. Porkbun remains the registrar. Existing apex and `ogm` A records remain DNS-only at `88.99.148.81`, TTL 600. +- Public reads expose all objects in this bucket. Store only public generated images here; no database dumps, private metadata, or credentials. +- R2 development URL stays disabled. CORS permits anonymous GET/HEAD from any origin and exposes ETag. + +Verified on 2026-10-26: registry delegation points to Cloudflare, the zone and R2 custom domain are active, and the production API docs still return HTTP 200. Cache rule `OGM immutable visual assets` is active for the asset hostname and `/assets/` prefix only. It respects origin Cache-Control, bypasses cache when that header is absent, and does not store HTTP status codes 400 or higher. A 75-byte PNG passed authenticated PUT/GET checksum verification and public HTTPS GET with CORS, one-year immutable Cache-Control, and `CF-Cache-Status: HIT`. Local DNS caches may temporarily retain the earlier negative lookup. + +Application rollout status: implemented locally, not deployed; production schema and existing image bodies are unchanged. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. + +## Release order + +1. Build and review a release containing the R2 changes. This workspace also contains unrelated ongoing features; do not deploy the entire dirty checkout implicitly. +2. Apply `python -m db.migrations.add_visual_asset_object_storage` with `PYTHONPATH=backend` and the target database URL, **before starting the new application or workers**, even in database mode. It adds nullable pointer columns and permits null bodies; it neither rewrites nor deletes existing bytes. The migration has a five-second lock timeout and can be repeated. +3. Supply the dedicated credentials to both web and worker containers (and mirror publisher source environments). Keep the default database mode until credentials and schema are ready. +4. Start a reversible pilot with: + + ```dotenv + VISUAL_ASSET_DURABLE_STORE=s3 + VISUAL_ASSET_S3_ENDPOINT=https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com + VISUAL_ASSET_S3_REGION=auto + VISUAL_ASSET_S3_BUCKET=ogm + VISUAL_ASSET_S3_PREFIX=assets + VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=true + VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED=false + VISUAL_ASSET_CACHE_TTL_SECONDS=86400 + VISUAL_ASSET_PUBLIC_BASE_URL= + ``` + + Also supply `VISUAL_ASSET_S3_ACCESS_KEY_ID` and `VISUAL_ASSET_S3_SECRET_ACCESS_KEY` through runtime secrets. An upload must succeed and its metadata must verify before a database pointer is published. Leaving the public URL blank exercises authenticated R2 reads with PostgreSQL fallback during the pilot. +5. Confirm a newly generated thumbnail, resource map, and institution map work with Redis cold. Check pointer, object size/checksum, MIME type, and service logs. Verify the custom-domain TLS certificate and public GET before enabling `VISUAL_ASSET_PUBLIC_BASE_URL=https://assets.geo4lib.app`. +6. Make extensionless objects cacheable with a Cloudflare Cache Rule matching only `http.host eq "assets.geo4lib.app"` and `starts_with(http.request.uri.path, "/assets/")`: eligible for cache, respect origin Cache-Control, do not cache errors. Check a second successful GET returns `CF-Cache-Status: HIT`. Object URLs have one-year immutable cache headers; API redirects use a shorter TTL because legacy IDs can resolve to newly generated bytes. +7. Backfill in bounded batches. With the target database and R2 credentials in the process environment: + + ```sh + PYTHONPATH=backend python backend/scripts/backfill_visual_assets_to_s3.py --limit 100 + PYTHONPATH=backend python backend/scripts/backfill_visual_assets_to_s3.py --apply --limit 100 + ``` + + Dry-run is the default. Apply uploads each image, GETs and checks actual bytes, and conditionally commits a pointer only if the source body is unchanged. Re-running skips rows already copied. Use the returned `last_hash` with `--after` for paging; run again without a cursor at the end to catch concurrent changes. No bodies are removed. A failure is safe to retry, though it may leave an unreferenced immutable object. +8. After the pilot and a successful nightly run, set `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` to stop retaining bytes on new writes. Do not remove historical database bytes or Redis keys as part of deployment; those need a separately reviewed inventory and reclaim procedure. + +## Retention and disk headroom + +Redis aliases receive a 24-hour TTL. New binary image cache writes are disabled in R2 mode by default. Existing non-expiring keys are unaffected. Redis DB 0 contains the Celery broker, and DB 1 also contains task results: never flush either database or apply indiscriminate eviction. Reclaim only positively identified visual cache keys whose durable copies have been verified, in small batches. Persisted RDB/AOF files may remain large until a planned rewrite; allow space for old and new files to coexist. + +Do not expire R2 objects merely because they are old: current database links and published mirror manifests may still reference them. A future garbage collector should inventory references, include mirror releases, wait at least 30 days after objects become unreferenced, dry-run first, and delete only a reviewed manifest. Keep the seven-day incomplete multipart upload abort rule. + +Removing PostgreSQL bytea values creates reusable database space but does not guarantee filesystem space is returned. Plan any table rewrite or repack separately with sufficient temporary disk and a lock/downtime budget. Avoid `VACUUM FULL` during reindexing. Keep production disk alerts and preflight checks independently of the image migration; Elasticsearch, logs, backups, and Redis persistence can still consume disk. + +## Rollback + +During the dual-write pilot, clear `VISUAL_ASSET_PUBLIC_BASE_URL` to stop redirects; set the durable store back to `database` only while every needed body is still present. Once any body is null, keep S3 reads enabled or restore those bytes before database-only rollback. Do not repoint the configured bucket or endpoint: persisted keys assume the same object store. Disabling redirects cannot instantly retract already cached immutable objects. + +For DNS rollback, Porkbun's previous nameservers were `curitiba.ns.porkbun.com`, `fortaleza.ns.porkbun.com`, `maceio.ns.porkbun.com`, and `salvador.ns.porkbun.com`; the original records were retained there. Nameserver rollback also propagates gradually. From efe38c16da95184d44dc0c2c83d30f5da8dc1a19 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 17:47:12 -0500 Subject: [PATCH 2/6] Bound R2 migration concurrency and gate local cleanup on verified copies --- .dockerignore | 2 + .../app/services/visual_asset_object_store.py | 21 ++- .../scripts/backfill_visual_assets_to_s3.py | 102 +++++++++---- .../scripts/prune_verified_visual_redis.py | 108 +++++++++++++ backend/scripts/verify_visual_assets_r2.py | 142 ++++++++++++++++++ .../services/test_visual_asset_backfill.py | 34 +++++ .../services/test_visual_asset_cleanup.py | 62 ++++++++ .../test_visual_asset_object_store.py | 43 +++++- config/deploy.yml | 2 +- docs/r2-visual-assets.md | 18 ++- 10 files changed, 494 insertions(+), 40 deletions(-) create mode 100644 backend/scripts/prune_verified_visual_redis.py create mode 100644 backend/scripts/verify_visual_assets_r2.py create mode 100644 backend/tests/services/test_visual_asset_cleanup.py diff --git a/.dockerignore b/.dockerignore index 07aa8cc..3ca084d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,3 +40,5 @@ backend/data/fixtures/* backend/data/gazetteers/* backend/data/opengeometadata/* backend/data/harvest_dumps/* + +.kamal/ diff --git a/backend/app/services/visual_asset_object_store.py b/backend/app/services/visual_asset_object_store.py index 0f0a907..c16212a 100644 --- a/backend/app/services/visual_asset_object_store.py +++ b/backend/app/services/visual_asset_object_store.py @@ -8,6 +8,7 @@ import boto3 from botocore.config import Config +from botocore.exceptions import ClientError def s3_visual_assets_enabled() -> bool: @@ -27,7 +28,7 @@ def _client(process_id: int): endpoint = _required("VISUAL_ASSET_S3_ENDPOINT") if urlsplit(endpoint).scheme != "https": raise ValueError("Visual object storage requires an HTTPS endpoint") - return boto3.client( + return boto3.session.Session().client( "s3", endpoint_url=endpoint, region_name=os.getenv("VISUAL_ASSET_S3_REGION", "auto"), @@ -37,6 +38,7 @@ def _client(process_id: int): signature_version="s3v4", connect_timeout=5, read_timeout=20, + max_pool_connections=128, retries={"total_max_attempts": 3, "mode": "standard"}, s3={"addressing_style": "path"}, request_checksum_calculation="when_required", @@ -67,7 +69,22 @@ def upload_visual_asset(body: bytes, content_type: str) -> tuple[str, str]: key, digest = object_key_for_body(body) s3 = client() name = bucket() - # Repeated uploads have identical bytes and keys, including concurrent renders. + # Content-addressed images are immutable. Reuse existing objects rather than + # rewriting common icons and maps (R2 limits repeated writes to one key). + try: + existing = s3.head_object(Bucket=name, Key=key) + except ClientError as exc: + # HEAD has no modeled NoSuchKey response; only an actual 404 is a miss. + if exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") != 404: + raise + else: + if ( + existing["ContentLength"] != len(body) + or existing.get("Metadata", {}).get("sha256") != digest + or existing.get("ContentType") != content_type + ): + raise RuntimeError("Existing visual asset metadata verification failed") + return key, digest s3.put_object( Bucket=name, Key=key, diff --git a/backend/scripts/backfill_visual_assets_to_s3.py b/backend/scripts/backfill_visual_assets_to_s3.py index 3a82349..62dd05e 100644 --- a/backend/scripts/backfill_visual_assets_to_s3.py +++ b/backend/scripts/backfill_visual_assets_to_s3.py @@ -1,8 +1,10 @@ """Copy durable image bodies to S3/R2 without deleting local data or changing URLs.""" import argparse +import hashlib +from threading import Lock import json -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait from sqlalchemy import select, update @@ -11,10 +13,34 @@ from db.session import sync_engine -def copy_row(row): +class VerifiedCopies: + """Share one verified upload among rows with identical bytes and MIME type.""" + + def __init__(self): + self.lock = Lock() + self.copies = {} + + def ensure(self, body, content_type): + identity = (hashlib.sha256(body).hexdigest(), content_type) + with self.lock: + future = self.copies.get(identity) + owner = future is None + if owner: + future = self.copies[identity] = Future() + if owner: + try: + key, digest = upload_visual_asset(body, content_type) + read_visual_asset(key, digest, len(body)) + future.set_result((key, digest)) + except Exception as exc: + future.set_exception(exc) + raise + return future.result() + + +def copy_row(row, copies): body = bytes(row["body"]) - key, digest = upload_visual_asset(body, row["content_type"]) - read_visual_asset(key, digest, len(body)) + key, digest = copies.ensure(body, row["content_type"]) with sync_engine.begin() as conn: result = conn.execute( update(assets) @@ -30,41 +56,59 @@ def copy_row(row): def backfill(*, apply=False, limit=100, after="", workers=1, progress=None): - copied = skipped = scanned = 0 + copied = skipped = scanned = completed = reported = 0 cursor = after + pending = set() + copies = VerifiedCopies() + exhausted = False with ThreadPoolExecutor(max_workers=workers) as pool: - while scanned < limit: - stmt = ( - select(assets.c.asset_hash, assets.c.body, assets.c.content_type) - .where( - assets.c.asset_hash > cursor, - assets.c.body.is_not(None), - assets.c.object_key.is_(None), + while pending or not exhausted: + if not exhausted and len(pending) < workers * 2: + stmt = ( + select(assets.c.asset_hash, assets.c.body, assets.c.content_type) + .where( + assets.c.asset_hash > cursor, + assets.c.body.is_not(None), + assets.c.object_key.is_(None), + ) + .order_by(assets.c.asset_hash) + .limit(min(25, limit - scanned)) ) - .order_by(assets.c.asset_hash) - .limit(min(25, limit - scanned)) - ) - with sync_engine.connect() as conn: - rows = conn.execute(stmt).mappings().all() - if not rows: - break - if apply: - # Serial default keeps embedded-database tests and small pilots simple. - results = map(copy_row, rows) if workers == 1 else pool.map(copy_row, rows) - for changed in results: + with sync_engine.connect() as conn: + rows = conn.execute(stmt).mappings().all() + if not rows: + exhausted = True + else: + for row in rows: + if apply and workers > 1: + pending.add(pool.submit(copy_row, row, copies)) + elif apply: + changed = copy_row(row, copies) + copied += changed + skipped += 1 - changed + completed += 1 + else: + completed += 1 + scanned += len(rows) + cursor = rows[-1]["asset_hash"] + exhausted = scanned >= limit + if pending and (exhausted or len(pending) >= workers * 2): + done, pending = wait(pending, return_when=FIRST_COMPLETED) + for future in done: + changed = future.result() copied += changed skipped += 1 - changed - scanned += len(rows) - cursor = rows[-1]["asset_hash"] - if progress and scanned % 500 == 0: + completed += 1 + if progress and completed - reported >= 500: progress( { "scanned": scanned, "copied": copied, "concurrently_changed": skipped, - "last_hash": cursor, + "in_flight": len(pending), } ) + reported = completed return { "scanned": scanned, "copied": copied, @@ -84,8 +128,8 @@ def main(): args = parser.parse_args() if args.limit < 1: parser.error("--limit must be positive") - if not 1 <= args.workers <= 16: - parser.error("--workers must be between 1 and 16") + if not 1 <= args.workers <= 128: + parser.error("--workers must be between 1 and 128") print( json.dumps( backfill( diff --git a/backend/scripts/prune_verified_visual_redis.py b/backend/scripts/prune_verified_visual_redis.py new file mode 100644 index 0000000..8d02482 --- /dev/null +++ b/backend/scripts/prune_verified_visual_redis.py @@ -0,0 +1,108 @@ +"""Remove only Redis DB 1 image bytes matching a verified R2 manifest.""" + +import argparse +import hashlib +import json +import os +import re + +import redis + +VISUAL_PREFIX = re.compile(r"^(image|static_map_asset|static_map_v[0-9]+|static_basemap_v[0-9]+):") +COMPARE_UNLINK = ( + "if redis.call('GET',KEYS[1]) == ARGV[1] then " + "return redis.call('UNLINK',KEYS[1]) else return 0 end" +) + + +def is_verified_image(key, value, verified): + return bool( + VISUAL_PREFIX.match(key.decode("utf-8")) + and value + and verified.get(hashlib.sha256(value).hexdigest()) == len(value) + ) + + +def prune(manifest, *, apply=False): + verified = {} + with open(manifest, encoding="utf-8") as source: + for line in source: + record = json.loads(line) + verified[record["object_sha256"]] = record["byte_size"] + if not verified: + raise ValueError("Verification manifest is empty") + cache = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD") or None, + db=1, + socket_timeout=30, + socket_connect_timeout=5, + ) + scanned = matched = removed = retained = released_bytes = 0 + pending = [] + + def batch(keys): + nonlocal scanned, matched, removed, retained, released_bytes + values = cache.mget(keys) + selected = [] + for key, value in zip(keys, values): + scanned += 1 + if is_verified_image(key, value, verified): + matched += 1 + selected.append((key, value)) + else: + retained += 1 + if apply and selected: + pipe = cache.pipeline(transaction=False) + for key, value in selected: + pipe.eval(COMPARE_UNLINK, 1, key, value) + for changed, (_, value) in zip(pipe.execute(), selected): + removed += changed + released_bytes += changed * len(value) + if scanned % 500 == 0: + print( + json.dumps( + dict( + scanned=scanned, + matched=matched, + removed=removed, + retained=retained, + released_bytes=released_bytes, + ) + ), + flush=True, + ) + + for key in cache.scan_iter(count=500): + if not VISUAL_PREFIX.match(key.decode("utf-8")): + continue + pending.append(key) + if len(pending) >= 25: + batch(pending) + pending = [] + if pending: + batch(pending) + result = dict( + scanned=scanned, + matched=matched, + removed=removed, + retained=retained, + released_bytes=released_bytes, + dry_run=not apply, + redis_db=1, + ) + print(json.dumps(result), flush=True) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + prune(args.manifest, apply=args.apply) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/verify_visual_assets_r2.py b/backend/scripts/verify_visual_assets_r2.py new file mode 100644 index 0000000..4c22f91 --- /dev/null +++ b/backend/scripts/verify_visual_assets_r2.py @@ -0,0 +1,142 @@ +"""Verify every remote image before optionally releasing its PostgreSQL body. + +The fsynced JSONL manifest is written before each small cleanup transaction. +An optimistic body/pointer comparison protects concurrent generation. +""" + +import argparse +import hashlib +import json +import os +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +from sqlalchemy import func, or_, select, update + +from app.services.visual_asset_object_store import client, read_visual_asset +from db.models import generated_visual_assets as assets +from db.session import sync_engine + + +def verified_record(row): + if not row["object_key"] or not row["object_sha256"]: + raise RuntimeError("Asset has no durable object pointer: " + row["asset_hash"]) + remote = read_visual_asset(row["object_key"], row["object_sha256"], row["byte_size"]) + if row["body"] is not None and bytes(row["body"]) != remote: + raise RuntimeError("Local/remote bytes differ: " + row["asset_hash"]) + return { + "asset_hash": row["asset_hash"], + "object_key": row["object_key"], + "object_sha256": hashlib.sha256(remote).hexdigest(), + "byte_size": len(remote), + "content_type": row["content_type"], + "verified_at": datetime.now(timezone.utc).isoformat(), + } + + +def clear_verified_body(conn, row): + if row["body"] is None: + return 0 + return conn.execute( + update(assets) + .where( + assets.c.asset_hash == row["asset_hash"], + assets.c.object_key == row["object_key"], + assets.c.object_sha256 == row["object_sha256"], + assets.c.byte_size == row["byte_size"], + assets.c.body == bytes(row["body"]), + ) + .values(body=None) + ).rowcount + + +def verify(*, manifest, prune=False, workers=8, limit=1000000, after=""): + if prune: + with sync_engine.connect() as conn: + missing = conn.execute( + select(func.count()) + .select_from(assets) + .where(or_(assets.c.object_key.is_(None), assets.c.object_sha256.is_(None))) + ).scalar_one() + if missing: + raise RuntimeError(f"Refusing cleanup: {missing} assets lack object pointers") + client() # Construct the SDK client before starting verification threads. + verified = cleared = released_bytes = 0 + cursor = after + os.umask(0o077) + with ( + open(manifest, "a", encoding="utf-8") as audit, + ThreadPoolExecutor(max_workers=workers) as pool, + ): + while verified < limit: + with sync_engine.connect() as conn: + rows = ( + conn.execute( + select(assets) + .where(assets.c.asset_hash > cursor) + .order_by(assets.c.asset_hash) + .limit(min(max(25, workers * 2), limit - verified)) + ) + .mappings() + .all() + ) + if not rows: + break + records = list(pool.map(verified_record, rows)) + for record in records: + audit.write(json.dumps(record) + "\n") + audit.flush() + os.fsync(audit.fileno()) + if prune: + with sync_engine.begin() as conn: + for row in rows: + changed = clear_verified_body(conn, row) + cleared += changed + released_bytes += changed * row["byte_size"] + verified += len(rows) + cursor = rows[-1]["asset_hash"] + if verified % 500 == 0: + print( + json.dumps( + { + "verified": verified, + "cleared": cleared, + "released_bytes": released_bytes, + "last_hash": cursor, + } + ), + flush=True, + ) + result = { + "verified": verified, + "cleared": cleared, + "released_bytes": released_bytes, + "last_hash": cursor, + "manifest": manifest, + "complete": verified < limit, + } + print(json.dumps(result), flush=True) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True) + parser.add_argument("--prune-postgres", action="store_true") + parser.add_argument("--workers", type=int, default=8) + parser.add_argument("--limit", type=int, default=1000000) + parser.add_argument("--after", default="") + args = parser.parse_args() + if not 1 <= args.workers <= 128 or args.limit < 1: + parser.error("workers must be 1..128 and limit positive") + verify( + manifest=args.manifest, + prune=args.prune_postgres, + workers=args.workers, + limit=args.limit, + after=args.after, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/services/test_visual_asset_backfill.py b/backend/tests/services/test_visual_asset_backfill.py index 5a5f5af..817ff22 100644 --- a/backend/tests/services/test_visual_asset_backfill.py +++ b/backend/tests/services/test_visual_asset_backfill.py @@ -83,3 +83,37 @@ def render(*args): assert result["copied"] == 0 assert row(database)["object_key"] is None assert row(database)["body"] == b"new image" + + +def test_parallel_copy_keeps_all_rows_and_commits_each_pointer(tmp_path, monkeypatch): + engine = create_engine("sqlite:///" + str(tmp_path / "assets.sqlite")) + assets.create(engine) + with engine.begin() as conn: + conn.execute( + assets.insert(), + [ + dict( + asset_hash=f"{n:064x}", + asset_kind="thumbnail", + content_type="image/png", + body=b"image", + byte_size=5, + ) + for n in range(80) + ], + ) + monkeypatch.setattr(migration, "sync_engine", engine) + with ( + patch.object( + migration, "upload_visual_asset", return_value=("assets/digest", "d" * 64) + ) as upload, + patch.object(migration, "read_visual_asset", return_value=b"image"), + ): + result = migration.backfill(apply=True, limit=100, workers=4) + upload.assert_called_once() + assert result["copied"] == 80 + assert result["scanned"] == 80 + with engine.connect() as conn: + rows = conn.execute(select(assets)).mappings().all() + assert all(r["object_key"] == "assets/digest" and r["body"] == b"image" for r in rows) + engine.dispose() diff --git a/backend/tests/services/test_visual_asset_cleanup.py b/backend/tests/services/test_visual_asset_cleanup.py new file mode 100644 index 0000000..018d069 --- /dev/null +++ b/backend/tests/services/test_visual_asset_cleanup.py @@ -0,0 +1,62 @@ +import hashlib +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select, update + +from db.models import generated_visual_assets as assets +from scripts import verify_visual_assets_r2 as cleanup + + +def sample(): + return dict( + asset_hash="a" * 64, + object_key="assets/test", + object_sha256=hashlib.sha256(b"image").hexdigest(), + body=b"image", + byte_size=5, + asset_kind="thumbnail", + content_type="image/png", + ) + + +def test_wrong_remote_copy_cannot_be_verified(): + with patch.object(cleanup, "read_visual_asset", return_value=b"wrong"): + with pytest.raises(RuntimeError, match="bytes differ"): + cleanup.verified_record(sample()) + + +def test_missing_pointer_cannot_be_verified(): + row = sample() + row["object_key"] = None + with pytest.raises(RuntimeError, match="no durable"): + cleanup.verified_record(row) + + +def test_clear_preserves_metadata_and_concurrent_new_body(): + engine = create_engine("sqlite://") + assets.create(engine) + row = sample() + with engine.begin() as conn: + conn.execute(assets.insert().values(**row)) + conn.execute(update(assets).values(body=b"newer")) + assert cleanup.clear_verified_body(conn, row) == 0 + assert conn.execute(select(assets.c.body)).scalar_one() == b"newer" + conn.execute(update(assets).values(body=b"image")) + assert cleanup.clear_verified_body(conn, row) == 1 + stored = conn.execute(select(assets)).mappings().one() + assert stored["body"] is None + assert stored["object_key"] == row["object_key"] + assert stored["byte_size"] == 5 + engine.dispose() + + +def test_redis_cleanup_requires_known_namespace_and_verified_exact_bytes(): + from scripts.prune_verified_visual_redis import is_verified_image + + manifest = {hashlib.sha256(b"image").hexdigest(): 5} + assert is_verified_image(b"image:hash", b"image", manifest) + assert is_verified_image(b"static_basemap_v7:resource", b"image", manifest) + assert not is_verified_image(b"celery-task-meta-id", b"image", manifest) + assert not is_verified_image(b"image_type:hash", b"image", manifest) + assert not is_verified_image(b"image:hash", b"other", manifest) diff --git a/backend/tests/services/test_visual_asset_object_store.py b/backend/tests/services/test_visual_asset_object_store.py index 822aa26..2045da2 100644 --- a/backend/tests/services/test_visual_asset_object_store.py +++ b/backend/tests/services/test_visual_asset_object_store.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest +from botocore.exceptions import ClientError from app.services import visual_asset_cache as cache from app.services import visual_asset_object_store as objects @@ -23,18 +24,23 @@ def test_upload_uses_actual_bytes_and_verifies_before_returning(): body = b"generated image" digest = hashlib.sha256(body).hexdigest() s3 = MagicMock() - s3.head_object.return_value = { - "ContentLength": len(body), - "ContentType": "image/png", - "Metadata": {"sha256": digest}, - } + s3.head_object.side_effect = [ + ClientError( + {"Error": {"Code": "404"}, "ResponseMetadata": {"HTTPStatusCode": 404}}, "HeadObject" + ), + { + "ContentLength": len(body), + "ContentType": "image/png", + "Metadata": {"sha256": digest}, + }, + ] with patch.object(objects, "client", return_value=s3): assert objects.upload_visual_asset(body, "image/png") == (f"assets/{digest}", digest) args = s3.put_object.call_args.kwargs assert args["Body"] == body assert args["ContentMD5"] assert args["CacheControl"] == "public, max-age=31536000, immutable" - s3.head_object.assert_called_once_with(Bucket="test-images", Key=f"assets/{digest}") + assert s3.head_object.call_count == 2 def test_incomplete_upload_is_not_published(): @@ -194,3 +200,28 @@ async def test_asset_endpoints_redirect_before_loading_bytes(kind): assert response.status_code == 302 assert response.headers["location"] == "https://assets.example.org/assets/hash" service.assert_not_called() + + +def test_existing_verified_object_is_reused_without_put(): + body = b"shared icon" + digest = hashlib.sha256(body).hexdigest() + s3 = MagicMock() + s3.head_object.return_value = { + "ContentLength": len(body), + "ContentType": "image/png", + "Metadata": {"sha256": digest}, + } + with patch.object(objects, "client", return_value=s3): + assert objects.upload_visual_asset(body, "image/png") == (f"assets/{digest}", digest) + s3.put_object.assert_not_called() + + +def test_head_permission_error_is_not_treated_as_missing(): + s3 = MagicMock() + s3.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied"}, "ResponseMetadata": {"HTTPStatusCode": 403}}, + "HeadObject", + ) + with patch.object(objects, "client", return_value=s3), pytest.raises(ClientError): + objects.upload_visual_asset(b"image", "image/png") + s3.put_object.assert_not_called() diff --git a/config/deploy.yml b/config/deploy.yml index 37a8954..bae0dd5 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -56,7 +56,7 @@ env: VISUAL_ASSET_DURABLE_STORE: s3 VISUAL_ASSET_CACHE_TTL_SECONDS: "86400" VISUAL_ASSET_REDIS_BODY_CACHE_ENABLED: "false" - VISUAL_ASSET_S3_RETAIN_DATABASE_BODY: "true" + VISUAL_ASSET_S3_RETAIN_DATABASE_BODY: "false" VISUAL_ASSET_S3_ENDPOINT: https://96d9819fafc99210217fc4e9aac9c73d.eu.r2.cloudflarestorage.com VISUAL_ASSET_S3_REGION: auto VISUAL_ASSET_S3_BUCKET: ogm diff --git a/docs/r2-visual-assets.md b/docs/r2-visual-assets.md index 6ee3804..2c86394 100644 --- a/docs/r2-visual-assets.md +++ b/docs/r2-visual-assets.md @@ -15,7 +15,7 @@ The optional `s3`/`r2` visual store puts generated image bytes in object storage Verified on 2026-10-26: registry delegation points to Cloudflare, the zone and R2 custom domain are active, and the production API docs still return HTTP 200. Cache rule `OGM immutable visual assets` is active for the asset hostname and `/assets/` prefix only. It respects origin Cache-Control, bypasses cache when that header is absent, and does not store HTTP status codes 400 or higher. A 75-byte PNG passed authenticated PUT/GET checksum verification and public HTTPS GET with CORS, one-year immutable Cache-Control, and `CF-Cache-Status: HIT`. Local DNS caches may temporarily retain the earlier negative lookup. -Application rollout status: implemented locally, not deployed; production schema and existing image bodies are unchanged. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. +Application rollout: MapLibre and R2 were deployed on 2026-10-26 from isolated production-based commit `652922f3`. Native campus and resource maps passed public HTTPS, dimensions, SHA-256, attribution, and cold-cache checks. Historical asset copying is in progress; original bodies remain intact until the separate verification pass. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. ## Release order @@ -47,7 +47,7 @@ Application rollout status: implemented locally, not deployed; production schema ``` Dry-run is the default. Apply uploads each image, GETs and checks actual bytes, and conditionally commits a pointer only if the source body is unchanged. Re-running skips rows already copied. Use the returned `last_hash` with `--after` for paging; run again without a cursor at the end to catch concurrent changes. No bodies are removed. A failure is safe to retry, though it may leave an unreferenced immutable object. -8. After the pilot and a successful nightly run, set `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` to stop retaining bytes on new writes. Do not remove historical database bytes or Redis keys as part of deployment; those need a separately reviewed inventory and reclaim procedure. +8. After the production pilot passes, set `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` to stop retaining bytes on new writes. Remove historical bodies only through the checksum-gated procedure below, after all rows have durable pointers. ## Retention and disk headroom @@ -62,3 +62,17 @@ Removing PostgreSQL bytea values creates reusable database space but does not gu During the dual-write pilot, clear `VISUAL_ASSET_PUBLIC_BASE_URL` to stop redirects; set the durable store back to `database` only while every needed body is still present. Once any body is null, keep S3 reads enabled or restore those bytes before database-only rollback. Do not repoint the configured bucket or endpoint: persisted keys assume the same object store. Disabling redirects cannot instantly retract already cached immutable objects. For DNS rollback, Porkbun's previous nameservers were `curitiba.ns.porkbun.com`, `fortaleza.ns.porkbun.com`, `maceio.ns.porkbun.com`, and `salvador.ns.porkbun.com`; the original records were retained there. Nameserver rollback also propagates gradually. + +## Verified cleanup procedure + +The copy-only utility bounds concurrency and shares a verified upload among rows with identical bytes. After a failed run, resume without `--after` so unfinished rows are included. Original bodies are retained. + +Before cleanup, export the schema, generated asset metadata (excluding bodies), and resource links to a protected directory and copy them off the production host. Never place these backups in the public bucket. Switch all running application roles to `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` and stop dual-write canaries. + +Run `verify_visual_assets_r2.py --manifest /protected/verified.jsonl --prune-postgres --workers 64` with the production environment. It refuses pruning while any pointer is missing, GETs and checksum-verifies every remote image, compares the existing local bytes, and fsyncs the audit manifest before each bounded cleanup transaction. Conditional updates protect concurrent changes. Metadata and resource links remain. + +Then run `prune_verified_visual_redis.py --manifest /protected/verified.jsonl` for a dry run; add `--apply` after reviewing the counts. It touches only known binary image namespaces in DB 1 whose bytes match a verified object digest and size. Lua compare-and-UNLINK preserves values changed concurrently. It leaves broker queues, task results, image types, aliases, and unmatched data untouched. + +Check filesystem headroom before ordinary PostgreSQL VACUUM and Redis persistence compaction. Run BGSAVE and BGREWRITEAOF one at a time and confirm persistence status before proceeding. Record actual filesystem space recovered, separately from logical body bytes released. + +During DNS propagation, the old Porkbun zone also has an `assets` A record at `104.21.18.253` (TTL 600), matching Cloudflare's active edge. Cloudflare remains authoritative; this compatibility record serves clients with a cached old delegation. From 71c0b184532b4e2fd4a64ca3abcac005384d6ed7 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 17:48:23 -0500 Subject: [PATCH 3/6] Retry transient R2 backfill throttling with bounded backoff --- backend/scripts/backfill_visual_assets_to_s3.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/scripts/backfill_visual_assets_to_s3.py b/backend/scripts/backfill_visual_assets_to_s3.py index 62dd05e..dfd4d46 100644 --- a/backend/scripts/backfill_visual_assets_to_s3.py +++ b/backend/scripts/backfill_visual_assets_to_s3.py @@ -4,6 +4,9 @@ import hashlib from threading import Lock import json +import random +import time +from botocore.exceptions import ClientError from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait from sqlalchemy import select, update @@ -29,7 +32,15 @@ def ensure(self, body, content_type): future = self.copies[identity] = Future() if owner: try: - key, digest = upload_visual_asset(body, content_type) + for attempt in range(6): + try: + key, digest = upload_visual_asset(body, content_type) + break + except ClientError as exc: + status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + if status not in (429, 500, 502, 503, 504) or attempt == 5: + raise + time.sleep(2**attempt + random.random()) read_visual_asset(key, digest, len(body)) future.set_result((key, digest)) except Exception as exc: From f438a6682ffd5cc69d2feff968f3e350eb5257d7 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 17:53:17 -0500 Subject: [PATCH 4/6] Test throttling retries and cleanup audit failure safeguards --- .../scripts/backfill_visual_assets_to_s3.py | 4 +-- .../services/test_visual_asset_backfill.py | 20 ++++++++++++ .../services/test_visual_asset_cleanup.py | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/scripts/backfill_visual_assets_to_s3.py b/backend/scripts/backfill_visual_assets_to_s3.py index dfd4d46..9b0b470 100644 --- a/backend/scripts/backfill_visual_assets_to_s3.py +++ b/backend/scripts/backfill_visual_assets_to_s3.py @@ -2,13 +2,13 @@ import argparse import hashlib -from threading import Lock import json import random import time -from botocore.exceptions import ClientError from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from threading import Lock +from botocore.exceptions import ClientError from sqlalchemy import select, update from app.services.visual_asset_object_store import read_visual_asset, upload_visual_asset diff --git a/backend/tests/services/test_visual_asset_backfill.py b/backend/tests/services/test_visual_asset_backfill.py index 817ff22..3c1c3ef 100644 --- a/backend/tests/services/test_visual_asset_backfill.py +++ b/backend/tests/services/test_visual_asset_backfill.py @@ -117,3 +117,23 @@ def test_parallel_copy_keeps_all_rows_and_commits_each_pointer(tmp_path, monkeyp rows = conn.execute(select(assets)).mappings().all() assert all(r["object_key"] == "assets/digest" and r["body"] == b"image" for r in rows) engine.dispose() + + +def test_transient_upload_failure_retries_without_committing_early(database): + from botocore.exceptions import ClientError + + error = ClientError( + {"Error": {"Code": "ServiceUnavailable"}, "ResponseMetadata": {"HTTPStatusCode": 503}}, + "PutObject", + ) + with ( + patch.object( + migration, "upload_visual_asset", side_effect=[error, ("assets/hash", "d" * 64)] + ) as upload, + patch.object(migration, "read_visual_asset", return_value=b"original"), + patch.object(migration.time, "sleep") as sleep, + ): + assert migration.backfill(apply=True)["copied"] == 1 + assert upload.call_count == 2 + sleep.assert_called_once() + assert row(database)["body"] == b"original" diff --git a/backend/tests/services/test_visual_asset_cleanup.py b/backend/tests/services/test_visual_asset_cleanup.py index 018d069..dd26344 100644 --- a/backend/tests/services/test_visual_asset_cleanup.py +++ b/backend/tests/services/test_visual_asset_cleanup.py @@ -60,3 +60,35 @@ def test_redis_cleanup_requires_known_namespace_and_verified_exact_bytes(): assert not is_verified_image(b"celery-task-meta-id", b"image", manifest) assert not is_verified_image(b"image_type:hash", b"image", manifest) assert not is_verified_image(b"image:hash", b"other", manifest) + + +def test_audit_flush_failure_prevents_cleanup(tmp_path, monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + with engine.begin() as conn: + conn.execute(assets.insert().values(**sample())) + monkeypatch.setattr(cleanup, "sync_engine", engine) + with ( + patch.object(cleanup, "client"), + patch.object(cleanup, "read_visual_asset", return_value=b"image"), + patch.object(cleanup.os, "fsync", side_effect=OSError("audit disk full")), + pytest.raises(OSError, match="audit disk full"), + ): + cleanup.verify(manifest=str(tmp_path / "audit.jsonl"), prune=True, workers=1) + with engine.connect() as conn: + assert conn.execute(select(assets.c.body)).scalar_one() == b"image" + engine.dispose() + + +def test_pruning_refuses_any_missing_object_pointer(tmp_path, monkeypatch): + engine = create_engine("sqlite://") + assets.create(engine) + row = sample() + row["object_key"] = None + with engine.begin() as conn: + conn.execute(assets.insert().values(**row)) + monkeypatch.setattr(cleanup, "sync_engine", engine) + with pytest.raises(RuntimeError, match="lack object pointers"): + cleanup.verify(manifest=str(tmp_path / "audit.jsonl"), prune=True, workers=1) + assert not (tmp_path / "audit.jsonl").exists() + engine.dispose() From cde0ccce00c8736c26519c65c203e967f45119f4 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 18:35:37 -0500 Subject: [PATCH 5/6] Use PostgreSQL row versions for efficient verified asset cleanup --- .../scripts/prune_verified_visual_redis.py | 8 +++++-- backend/scripts/verify_visual_assets_r2.py | 22 ++++++++++++++----- docs/r2-visual-assets.md | 12 +++++++++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/backend/scripts/prune_verified_visual_redis.py b/backend/scripts/prune_verified_visual_redis.py index 8d02482..be15a01 100644 --- a/backend/scripts/prune_verified_visual_redis.py +++ b/backend/scripts/prune_verified_visual_redis.py @@ -39,20 +39,22 @@ def prune(manifest, *, apply=False): socket_timeout=30, socket_connect_timeout=5, ) - scanned = matched = removed = retained = released_bytes = 0 + scanned = matched = removed = retained = released_bytes = eligible_bytes = retained_bytes = 0 pending = [] def batch(keys): - nonlocal scanned, matched, removed, retained, released_bytes + nonlocal scanned, matched, removed, retained, released_bytes, eligible_bytes, retained_bytes values = cache.mget(keys) selected = [] for key, value in zip(keys, values): scanned += 1 if is_verified_image(key, value, verified): matched += 1 + eligible_bytes += len(value) selected.append((key, value)) else: retained += 1 + retained_bytes += len(value) if value else 0 if apply and selected: pipe = cache.pipeline(transaction=False) for key, value in selected: @@ -89,6 +91,8 @@ def batch(keys): removed=removed, retained=retained, released_bytes=released_bytes, + eligible_bytes=eligible_bytes, + retained_bytes=retained_bytes, dry_run=not apply, redis_db=1, ) diff --git a/backend/scripts/verify_visual_assets_r2.py b/backend/scripts/verify_visual_assets_r2.py index 4c22f91..c62ce9a 100644 --- a/backend/scripts/verify_visual_assets_r2.py +++ b/backend/scripts/verify_visual_assets_r2.py @@ -11,7 +11,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone -from sqlalchemy import func, or_, select, update +from sqlalchemy import Text, cast, func, literal_column, or_, select, update from app.services.visual_asset_object_store import client, read_visual_asset from db.models import generated_visual_assets as assets @@ -37,6 +37,11 @@ def verified_record(row): def clear_verified_body(conn, row): if row["body"] is None: return 0 + unchanged = ( + cast(literal_column("xmin"), Text) == row["_row_version"] + if "_row_version" in row + else assets.c.body == bytes(row["body"]) + ) return conn.execute( update(assets) .where( @@ -44,7 +49,8 @@ def clear_verified_body(conn, row): assets.c.object_key == row["object_key"], assets.c.object_sha256 == row["object_sha256"], assets.c.byte_size == row["byte_size"], - assets.c.body == bytes(row["body"]), + assets.c.body.is_not(None), + unchanged, ) .values(body=None) ).rowcount @@ -61,8 +67,13 @@ def verify(*, manifest, prune=False, workers=8, limit=1000000, after=""): if missing: raise RuntimeError(f"Refusing cleanup: {missing} assets lack object pointers") client() # Construct the SDK client before starting verification threads. - verified = cleared = released_bytes = 0 + verified = cleared = released_bytes = reported = 0 cursor = after + columns = [assets] + if sync_engine.dialect.name == "postgresql": + # xmin changes on every concurrent UPDATE, including body-only updates. + # Avoid shipping every large image back in the cleanup WHERE clause. + columns.append(cast(literal_column("xmin"), Text).label("_row_version")) os.umask(0o077) with ( open(manifest, "a", encoding="utf-8") as audit, @@ -72,7 +83,7 @@ def verify(*, manifest, prune=False, workers=8, limit=1000000, after=""): with sync_engine.connect() as conn: rows = ( conn.execute( - select(assets) + select(*columns) .where(assets.c.asset_hash > cursor) .order_by(assets.c.asset_hash) .limit(min(max(25, workers * 2), limit - verified)) @@ -95,7 +106,8 @@ def verify(*, manifest, prune=False, workers=8, limit=1000000, after=""): released_bytes += changed * row["byte_size"] verified += len(rows) cursor = rows[-1]["asset_hash"] - if verified % 500 == 0: + if verified - reported >= 500: + reported = verified print( json.dumps( { diff --git a/docs/r2-visual-assets.md b/docs/r2-visual-assets.md index 2c86394..101c250 100644 --- a/docs/r2-visual-assets.md +++ b/docs/r2-visual-assets.md @@ -69,10 +69,20 @@ The copy-only utility bounds concurrency and shares a verified upload among rows Before cleanup, export the schema, generated asset metadata (excluding bodies), and resource links to a protected directory and copy them off the production host. Never place these backups in the public bucket. Switch all running application roles to `VISUAL_ASSET_S3_RETAIN_DATABASE_BODY=false` and stop dual-write canaries. -Run `verify_visual_assets_r2.py --manifest /protected/verified.jsonl --prune-postgres --workers 64` with the production environment. It refuses pruning while any pointer is missing, GETs and checksum-verifies every remote image, compares the existing local bytes, and fsyncs the audit manifest before each bounded cleanup transaction. Conditional updates protect concurrent changes. Metadata and resource links remain. +Run `verify_visual_assets_r2.py --manifest /protected/verified.jsonl --prune-postgres --workers 64` with the production environment. It refuses pruning while any pointer is missing, GETs and checksum-verifies every remote image, compares the existing local bytes, and fsyncs the audit manifest before each bounded cleanup transaction. Conditional updates protect concurrent changes. PostgreSQL uses the observed `xmin` row version, so even a concurrent body-only update prevents deletion without resending large image bodies in SQL; other database backends use byte equality. This guard was verified against a real concurrent update in an isolated PostgreSQL 15 instance. Metadata and resource links remain. Then run `prune_verified_visual_redis.py --manifest /protected/verified.jsonl` for a dry run; add `--apply` after reviewing the counts. It touches only known binary image namespaces in DB 1 whose bytes match a verified object digest and size. Lua compare-and-UNLINK preserves values changed concurrently. It leaves broker queues, task results, image types, aliases, and unmatched data untouched. Check filesystem headroom before ordinary PostgreSQL VACUUM and Redis persistence compaction. Run BGSAVE and BGREWRITEAOF one at a time and confirm persistence status before proceeding. Record actual filesystem space recovered, separately from logical body bytes released. During DNS propagation, the old Porkbun zone also has an `assets` A record at `104.21.18.253` (TTL 600), matching Cloudflare's active edge. Cloudflare remains authoritative; this compatibility record serves clients with a cached old delegation. + +## Production verification updates (2026-10-26) + +The final application configuration was deployed to worker, web, and cron from `71c0b184532b4e2fd4a64ca3abcac005384d6ed7`. A newly generated Indiana campus map (zoom 14, 640×320, 170141 bytes) returned HTTP 200 through the public asset domain with SHA-256 `7387541d7b960d5cf14d0478b0392ac45a0175534a1b67f223d52239081c2a70`; its PostgreSQL body is NULL. The private dual-write canary is stopped. + +Cloudflare configuration rule `OGM public asset client compatibility` (`a6599194233844b0845e50dbc607463c`) disables only Browser Integrity Check for GET/HEAD on `assets.geo4lib.app` under `/assets/`. This approved exception fixes Cloudflare error 1010 for Python urllib downloads. Other hosts, paths, methods, and security settings are unchanged; the urllib request was verified afterward. + +Release PR #25 passed all three GitHub checks, including 2014 passed, 82 skipped, and 1 xpassed backend tests. The production asset copy and subsequent cleanup are tracked separately from application deployment. + +During the 2026-10-26 cleanup, Redis's automatic snapshot temporarily required a second ~15 GB file. It completed successfully. Maintenance then temporarily set runtime `save` to an empty string while retaining the fresh RDB and healthy AOF (`appendfsync everysec`). Restore `CONFIG SET save "3600 1 300 100 60 10000"` after compaction; do not leave the maintenance override in place. No persistent configuration rewrite was made. From a7851d91b0724ca8cdc89c82d255351352ccb101 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 26 Oct 2026 19:11:38 -0500 Subject: [PATCH 6/6] Record verified production R2 migration and disk recovery --- docs/r2-visual-assets.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/r2-visual-assets.md b/docs/r2-visual-assets.md index 101c250..557f632 100644 --- a/docs/r2-visual-assets.md +++ b/docs/r2-visual-assets.md @@ -13,9 +13,9 @@ The optional `s3`/`r2` visual store puts generated image bytes in object storage - Public reads expose all objects in this bucket. Store only public generated images here; no database dumps, private metadata, or credentials. - R2 development URL stays disabled. CORS permits anonymous GET/HEAD from any origin and exposes ETag. -Verified on 2026-10-26: registry delegation points to Cloudflare, the zone and R2 custom domain are active, and the production API docs still return HTTP 200. Cache rule `OGM immutable visual assets` is active for the asset hostname and `/assets/` prefix only. It respects origin Cache-Control, bypasses cache when that header is absent, and does not store HTTP status codes 400 or higher. A 75-byte PNG passed authenticated PUT/GET checksum verification and public HTTPS GET with CORS, one-year immutable Cache-Control, and `CF-Cache-Status: HIT`. Local DNS caches may temporarily retain the earlier negative lookup. +Verified on 2026-10-26: registry delegation points to Cloudflare, the zone and R2 custom domain are active, and the production API docs still return HTTP 200. Cache rule `OGM immutable visual assets` is active for the asset hostname and `/assets/` prefix only. It respects origin Cache-Control, bypasses cache when that header is absent, and does not store HTTP status codes 400 or higher. A 75-byte PNG passed authenticated PUT/GET checksum verification and public HTTPS GET with CORS, one-year immutable Cache-Control, and `CF-Cache-Status: HIT`. Public DNS and normal local HTTPS resolution were verified after propagation. -Application rollout: MapLibre and R2 were deployed on 2026-10-26 from isolated production-based commit `652922f3`. Native campus and resource maps passed public HTTPS, dimensions, SHA-256, attribution, and cold-cache checks. Historical asset copying is in progress; original bodies remain intact until the separate verification pass. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. +Application rollout: MapLibre and R2 were deployed on 2026-10-26 from isolated production-based commit `652922f3`. Native campus and resource maps passed public HTTPS, dimensions, SHA-256, attribution, and cold-cache checks. Historical asset copying, checksum verification, and local cleanup are complete; production now stores image bodies only in R2. Validation: 89 focused tests passed; the additive migration was also run repeatedly against an isolated PostgreSQL 15 instance, preserving old bytes and accepting object-only rows. The temporary database container was removed after verification. ## Release order @@ -83,6 +83,18 @@ The final application configuration was deployed to worker, web, and cron from ` Cloudflare configuration rule `OGM public asset client compatibility` (`a6599194233844b0845e50dbc607463c`) disables only Browser Integrity Check for GET/HEAD on `assets.geo4lib.app` under `/assets/`. This approved exception fixes Cloudflare error 1010 for Python urllib downloads. Other hosts, paths, methods, and security settings are unchanged; the urllib request was verified afterward. -Release PR #25 passed all three GitHub checks, including 2014 passed, 82 skipped, and 1 xpassed backend tests. The production asset copy and subsequent cleanup are tracked separately from application deployment. +Release PR #25 passed all three GitHub checks for the application and cleanup code. Production delivery was rechecked after PostgreSQL and Redis cleanup for all eight asset kinds, including HTTP 200, SHA-256, MIME type, R2 redirects, and CORS. Celery responded to ping and Elasticsearch was green without a disk write block. -During the 2026-10-26 cleanup, Redis's automatic snapshot temporarily required a second ~15 GB file. It completed successfully. Maintenance then temporarily set runtime `save` to an empty string while retaining the fresh RDB and healthy AOF (`appendfsync everysec`). Restore `CONFIG SET save "3600 1 300 100 60 10000"` after compaction; do not leave the maintenance override in place. No persistent configuration rewrite was made. +During the 2026-10-26 cleanup, Redis's automatic snapshot temporarily required a second ~15 GB file. It completed successfully. Maintenance then temporarily set runtime `save` to an empty string while retaining the fresh RDB and healthy AOF (`appendfsync everysec`). After successful BGSAVE and BGREWRITEAOF, the original `save` schedule (`3600 1 300 100 60 10000`) was restored and verified. AOF remained enabled with `appendfsync everysec` throughout. No persistent configuration rewrite was made. + +## Completed migration and reclaim (2026-10-27 UTC) + +- All 261,905 asset records have R2 pointers, representing 255,240 distinct immutable objects and 29,572,717,107 logical image bytes. No PostgreSQL image bodies remain; all metadata and resource links are preserved. +- Before clearing bodies, a protected snapshot exported 261,905 metadata rows and 824,809 resource links. The approved off-server copy is in the release worktree's ignored, mode-0700 `.kamal/backup-20261026/` directory; files are mode 0600. Both CSV row counts and SHA-256 values were checked, along with the current schema checksum. +- The completed audit contains all 261,905 unique asset IDs. Its SHA-256 is `607ea7e6f09f2adb6e81fa6e78c1bdfe1981ae1bd511be9a3437838ac36149b6`. A copy is in the private backup directory. This audit and the private backups must not be published in the image bucket or committed to Git. +- Redis DB 1 cleanup removed 249,661 matching image values (16,263,095,154 bytes). Seven unmatched values totaling 981,747 bytes were retained. DB 0's 662,722 keys were unchanged; DB 1 decreased by exactly 249,661 keys. Task results, image types, aliases, and the Celery broker were preserved. +- Redis used memory fell to about 231 MiB. Its snapshot and AOF each compacted to about 119 MiB; both persistence operations completed successfully in three seconds. +- Ordinary VACUUM removed 3,982,962 empty TOAST pages without a table rewrite. The complete asset relation is now 606,568,448 bytes (about 579 MiB). +- Final measured filesystem availability: 106,235,396,096 bytes (98.9 GiB), 77% used, versus roughly 36 GiB available immediately before cleanup. This is approximately 63 GiB of additional headroom. + +R2 is now required for existing images. Database-only rollback requires restoring the verified image bytes first; retain the R2-compatible application release and credentials. New image bodies do not accumulate in PostgreSQL or Redis, aliases receive a 24-hour TTL, and no age-only deletion policy was applied to R2 objects.