From c41d2174a01773cff3f787db795af6408a50a9e8 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 19:58:14 +0800 Subject: [PATCH 01/10] feat(enrichment): rename image/screenshot fields and table to thumbnailImage/captureImage/enrichment_captures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EnrichmentResult.image → thumbnailImage, screenshot → captureImage, add previewImage - EnrichmentScreenshot/Palette types → EnrichmentCapture/CapturePalette - enrichment_screenshots table → enrichment_captures (migration 0014) - enrichment-screenshot.repository → enrichment-capture.repository - screenshot-pipeline/screenshot-storage services → capture-pipeline/capture-storage - api-client bumped to 4.3.0 with updated EnrichmentResult shape --- apps/core/src/constants/cache.constant.ts | 4 +- .../migrations/0014_enrichment_captures.sql | 13 + .../migrations/meta/0014_snapshot.json | 5661 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + ...ry.ts => enrichment-capture.repository.ts} | 96 +- .../enrichment/enrichment.controller.ts | 70 +- .../modules/enrichment/enrichment.module.ts | 12 +- .../enrichment/enrichment.repository.ts | 22 +- .../modules/enrichment/enrichment.schema.ts | 6 +- .../modules/enrichment/enrichment.service.ts | 59 +- .../modules/enrichment/enrichment.types.ts | 26 +- .../providers/bangumi/bangumi.provider.ts | 2 +- .../github/github-commit.provider.ts | 2 +- .../github/github-discussion.provider.ts | 2 +- .../providers/github/github-issue.provider.ts | 2 +- .../providers/github/github-pr.provider.ts | 2 +- .../providers/github/github-repo.provider.ts | 2 +- .../providers/neodb/neodb-book.provider.ts | 2 +- .../netease/netease-music.provider.ts | 2 +- ...service.ts => capture-pipeline.service.ts} | 20 +- ....service.ts => capture-storage.service.ts} | 62 +- .../providers/open-graph/og-parser.ts | 10 +- .../open-graph/open-graph.provider.ts | 2 +- .../providers/tmdb/tmdb.provider.ts | 2 +- ...s => 20260512-enrichment-captures.spec.ts} | 31 +- ...ec.ts => capture-pipeline.service.spec.ts} | 12 +- ...pec.ts => capture-storage.service.spec.ts} | 97 +- .../enrichment-admin.controller.e2e-spec.ts | 103 +- .../enrichment/enrichment.service.spec.ts | 24 +- ...=> open-graph-capture.integration.spec.ts} | 84 +- .../github-discussion.provider.spec.ts | 7 +- .../providers/github-repo.provider.spec.ts | 4 +- .../providers/open-graph.parser.spec.ts | 26 +- .../providers/tmdb.provider.spec.ts | 2 +- packages/api-client/models/recently.ts | 17 +- packages/api-client/package.json | 2 +- packages/db-schema/src/schema/enrichment.ts | 10 +- 37 files changed, 6093 insertions(+), 414 deletions(-) create mode 100644 apps/core/src/database/migrations/0014_enrichment_captures.sql create mode 100644 apps/core/src/database/migrations/meta/0014_snapshot.json rename apps/core/src/modules/enrichment/{enrichment-screenshot.repository.ts => enrichment-capture.repository.ts} (65%) rename apps/core/src/modules/enrichment/providers/open-graph/{screenshot-pipeline.service.ts => capture-pipeline.service.ts} (91%) rename apps/core/src/modules/enrichment/providers/open-graph/{screenshot-storage.service.ts => capture-storage.service.ts} (81%) rename apps/core/test/src/database/app-migrations/{20260512-enrichment-screenshots.spec.ts => 20260512-enrichment-captures.spec.ts} (79%) rename apps/core/test/src/modules/enrichment/{screenshot-pipeline.service.spec.ts => capture-pipeline.service.spec.ts} (92%) rename apps/core/test/src/modules/enrichment/{screenshot-storage.service.spec.ts => capture-storage.service.spec.ts} (86%) rename apps/core/test/src/modules/enrichment/{open-graph-screenshot.integration.spec.ts => open-graph-capture.integration.spec.ts} (81%) diff --git a/apps/core/src/constants/cache.constant.ts b/apps/core/src/constants/cache.constant.ts index b0243c1af3d..00290629611 100644 --- a/apps/core/src/constants/cache.constant.ts +++ b/apps/core/src/constants/cache.constant.ts @@ -33,8 +33,8 @@ export enum RedisKeys { AnalyzeTrafficSource = 'analyze_traffic_source', AnalyzeDeviceDistribution = 'analyze_device_distribution', - /** Enrichment 截图 LRU touchAccess 节流 NX 锁 */ - EnrichmentScreenshotTouch = 'enrichment_screenshot_touch', + /** Enrichment capture LRU touchAccess 节流 NX 锁 */ + EnrichmentCaptureTouch = 'enrichment_capture_touch', } export const API_CACHE_PREFIX = 'mx-api-cache:' export enum CacheKeys { diff --git a/apps/core/src/database/migrations/0014_enrichment_captures.sql b/apps/core/src/database/migrations/0014_enrichment_captures.sql new file mode 100644 index 00000000000..0f62fde9264 --- /dev/null +++ b/apps/core/src/database/migrations/0014_enrichment_captures.sql @@ -0,0 +1,13 @@ +ALTER TABLE "enrichment_screenshots" RENAME TO "enrichment_captures"; +--> statement-breakpoint +ALTER INDEX "enrichment_screenshots_lru_idx" RENAME TO "enrichment_captures_lru_idx"; +--> statement-breakpoint +ALTER TABLE "enrichment_captures" RENAME CONSTRAINT "enrichment_screenshots_enrichment_id_enrichment_cache_id_fk" TO "enrichment_captures_enrichment_id_enrichment_cache_id_fk"; +--> statement-breakpoint +UPDATE "enrichment_cache" +SET "normalized" = ( + "normalized" + || jsonb_build_object('thumbnailImage', "normalized" -> 'image') + || jsonb_build_object('captureImage', "normalized" -> 'screenshot') + ) - 'image' - 'screenshot' +WHERE "normalized" ? 'image' OR "normalized" ? 'screenshot'; diff --git a/apps/core/src/database/migrations/meta/0014_snapshot.json b/apps/core/src/database/migrations/meta/0014_snapshot.json new file mode 100644 index 00000000000..49b8ae03ff5 --- /dev/null +++ b/apps/core/src/database/migrations/meta/0014_snapshot.json @@ -0,0 +1,5661 @@ +{ + "id": "f5b8c2a1-3470-4d8e-a277-7c8b8d9f0014", + "prevId": "c0a072a1-9870-4f7e-b277-e7fd8bfaad1d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_agent_conversations": { + "name": "ai_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "diff_state": { + "name": "diff_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "ai_agent_conversations_ref_idx": { + "name": "ai_agent_conversations_ref_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_agent_conversations_updated_at_idx": { + "name": "ai_agent_conversations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_insights": { + "name": "ai_insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_translation": { + "name": "is_translation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_insights_id": { + "name": "source_insights_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_info": { + "name": "model_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_insights_ref_lang_uniq": { + "name": "ai_insights_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_insights_source_insights_id_ai_insights_id_fk": { + "name": "ai_insights_source_insights_id_ai_insights_id_fk", + "tableFrom": "ai_insights", + "tableTo": "ai_insights", + "columnsFrom": [ + "source_insights_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_summaries": { + "name": "ai_summaries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_summaries_ref_id_idx": { + "name": "ai_summaries_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_translations": { + "name": "ai_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_lang": { + "name": "source_lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_provider": { + "name": "ai_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_block_snapshots": { + "name": "source_block_snapshots", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_meta_hashes": { + "name": "source_meta_hashes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_translations_ref_lang_uniq": { + "name": "ai_translations_ref_lang_uniq", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_translations_ref_id_idx": { + "name": "ai_translations_ref_id_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translation_entries": { + "name": "translation_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "key_path": { + "name": "key_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lookup_key": { + "name": "lookup_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "translated_text": { + "name": "translated_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "translation_entries_key_uniq": { + "name": "translation_entries_key_uniq", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_path_lang_idx": { + "name": "translation_entries_path_lang_idx", + "columns": [ + { + "expression": "key_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "translation_entries_lookup_key_idx": { + "name": "translation_entries_lookup_key_idx", + "columns": [ + { + "expression": "lookup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_uniq": { + "name": "accounts_provider_uniq", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_readers_id_fk": { + "name": "accounts_user_id_readers_id_fk", + "tableFrom": "accounts", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_uniq": { + "name": "api_keys_key_uniq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_readers_id_fk": { + "name": "api_keys_user_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_reference_id_readers_id_fk": { + "name": "api_keys_reference_id_readers_id_fk", + "tableFrom": "api_keys", + "tableTo": "readers", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_codes": { + "name": "device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_codes_device_code_uniq": { + "name": "device_codes_device_code_uniq", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_user_code_uniq": { + "name": "device_codes_user_code_uniq", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_codes_expires_at_idx": { + "name": "device_codes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_codes_user_id_readers_id_fk": { + "name": "device_codes_user_id_readers_id_fk", + "tableFrom": "device_codes", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.owner_profiles": { + "name": "owner_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_ip": { + "name": "last_login_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_time": { + "name": "last_login_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "social_ids": { + "name": "social_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "owner_profiles_reader_id_uniq": { + "name": "owner_profiles_reader_id_uniq", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "owner_profiles_reader_id_readers_id_fk": { + "name": "owner_profiles_reader_id_readers_id_fk", + "tableFrom": "owner_profiles", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkeys_credential_id_uniq": { + "name": "passkeys_credential_id_uniq", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_readers_id_fk": { + "name": "passkeys_user_id_readers_id_fk", + "tableFrom": "passkeys", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.readers": { + "name": "readers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reader'" + } + }, + "indexes": { + "readers_email_uniq": { + "name": "readers_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_username_uniq": { + "name": "readers_username_uniq", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"readers\".\"username\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "readers_role_idx": { + "name": "readers_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_token_uniq": { + "name": "sessions_token_uniq", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_readers_id_fk": { + "name": "sessions_user_id_readers_id_fk", + "tableFrom": "sessions", + "tableTo": "readers", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "categories_name_uniq": { + "name": "categories_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_slug_uniq": { + "name": "categories_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mail": { + "name": "mail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_comment_id": { + "name": "root_comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "latest_reply_at": { + "name": "latest_reply_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_whispers": { + "name": "is_whispers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_thread_idx": { + "name": "comments_thread_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_root_idx": { + "name": "comments_root_idx", + "columns": [ + { + "expression": "root_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_reader_idx": { + "name": "comments_reader_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_parent_comment_id_comments_id_fk": { + "name": "comments_parent_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_root_comment_id_comments_id_fk": { + "name": "comments_root_comment_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "root_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_reader_id_readers_id_fk": { + "name": "comments_reader_id_readers_id_fk", + "tableFrom": "comments", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_histories": { + "name": "draft_histories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "draft_id": { + "name": "draft_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_full_snapshot": { + "name": "is_full_snapshot", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "ref_version": { + "name": "ref_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "base_version": { + "name": "base_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "draft_histories_draft_version_uniq": { + "name": "draft_histories_draft_version_uniq", + "columns": [ + { + "expression": "draft_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "draft_histories_draft_id_drafts_id_fk": { + "name": "draft_histories_draft_id_drafts_id_fk", + "tableFrom": "draft_histories", + "tableTo": "drafts", + "columnsFrom": [ + "draft_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type_specific_data": { + "name": "type_specific_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "published_version": { + "name": "published_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drafts_ref_idx": { + "name": "drafts_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"drafts\".\"ref_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_updated_at_idx": { + "name": "drafts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "notes_nid_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weather": { + "name": "weather", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bookmark": { + "name": "bookmark", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "coordinates": { + "name": "coordinates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notes_nid_uniq": { + "name": "notes_nid_uniq", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_slug_uniq": { + "name": "notes_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notes\".\"slug\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_nid_desc_idx": { + "name": "notes_nid_desc_idx", + "columns": [ + { + "expression": "nid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_modified_at_idx": { + "name": "notes_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_created_at_idx": { + "name": "notes_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_topic_id_idx": { + "name": "notes_topic_id_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notes_published_public_created_idx": { + "name": "notes_published_public_created_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notes_topic_id_topics_id_fk": { + "name": "notes_topic_id_topics_id_fk", + "tableFrom": "notes", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pages_slug_uniq": { + "name": "pages_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_order_idx": { + "name": "pages_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_related_posts": { + "name": "post_related_posts", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "related_post_id": { + "name": "related_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "post_related_posts_pk": { + "name": "post_related_posts_pk", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_related_posts_related_idx": { + "name": "post_related_posts_related_idx", + "columns": [ + { + "expression": "related_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_related_posts_post_id_posts_id_fk": { + "name": "post_related_posts_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_related_posts_related_post_id_posts_id_fk": { + "name": "post_related_posts_related_post_id_posts_id_fk", + "tableFrom": "post_related_posts", + "tableTo": "posts", + "columnsFrom": [ + "related_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "copyright": { + "name": "copyright", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pin_at": { + "name": "pin_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pin_order": { + "name": "pin_order", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_slug_uniq": { + "name": "posts_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_modified_at_idx": { + "name": "posts_modified_at_idx", + "columns": [ + { + "expression": "modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_category_id_idx": { + "name": "posts_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_published_created_at_idx": { + "name": "posts_published_created_at_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_category_published_created_idx": { + "name": "posts_category_published_created_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pin_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "posts_tags_gin_idx": { + "name": "posts_tags_gin_idx", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "posts_category_id_categories_id_fk": { + "name": "posts_category_id_categories_id_fk", + "tableFrom": "posts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recentlies": { + "name": "recentlies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comments_index": { + "name": "comments_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_comment": { + "name": "allow_comment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "up": { + "name": "up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "down": { + "name": "down", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "recentlies_ref_idx": { + "name": "recentlies_ref_idx", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recentlies_created_at_idx": { + "name": "recentlies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "introduce": { + "name": "introduce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "topics_name_uniq": { + "name": "topics_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_slug_uniq": { + "name": "topics_slug_uniq", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_cache": { + "name": "enrichment_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "normalized": { + "name": "normalized", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_provider_external_id_locale_uniq": { + "name": "enrichment_provider_external_id_locale_uniq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrichment_expires_at_idx": { + "name": "enrichment_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_captures": { + "name": "enrichment_captures", + "schema": "", + "columns": { + "enrichment_id": { + "name": "enrichment_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blurhash": { + "name": "blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "palette": { + "name": "palette", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrichment_captures_lru_idx": { + "name": "enrichment_captures_lru_idx", + "columns": [ + { + "expression": "last_accessed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_captures_enrichment_id_enrichment_cache_id_fk": { + "name": "enrichment_captures_enrichment_id_enrichment_cache_id_fk", + "tableFrom": "enrichment_captures", + "tableTo": "enrichment_cache", + "columnsFrom": [ + "enrichment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._app_migrations": { + "name": "_app_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_id_map": { + "name": "auth_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pg_id": { + "name": "pg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_id_map_collection_mongo_uniq": { + "name": "auth_id_map_collection_mongo_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_id_map_collection_pg_uniq": { + "name": "auth_id_map_collection_pg_uniq", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_migration_runs": { + "name": "data_migration_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mongo_id_map": { + "name": "mongo_id_map", + "schema": "", + "columns": { + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mongo_id": { + "name": "mongo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snowflake_id": { + "name": "snowflake_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mongo_id_map_pk": { + "name": "mongo_id_map_pk", + "columns": [ + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mongo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mongo_id_map_snowflake_uniq": { + "name": "mongo_id_map_snowflake_uniq", + "columns": [ + { + "expression": "snowflake_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schema_migrations": { + "name": "schema_migrations", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activities": { + "name": "activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activities_created_at_idx": { + "name": "activities_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analyzes": { + "name": "analyzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ua": { + "name": "ua", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referer": { + "name": "referer", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "analyzes_timestamp_idx": { + "name": "analyzes_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_path_idx": { + "name": "analyzes_timestamp_path_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_referer_idx": { + "name": "analyzes_timestamp_referer_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "referer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analyzes_timestamp_ip_idx": { + "name": "analyzes_timestamp_ip_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_references": { + "name": "file_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_object_key": { + "name": "s3_object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reader_id": { + "name": "reader_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_references_file_url_idx": { + "name": "file_references_file_url_idx", + "columns": [ + { + "expression": "file_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_ref_idx": { + "name": "file_references_ref_idx", + "columns": [ + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_created_idx": { + "name": "file_references_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_reader_status_created_idx": { + "name": "file_references_reader_status_created_idx", + "columns": [ + { + "expression": "reader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_references_status_detached_idx": { + "name": "file_references_status_detached_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_references_reader_id_readers_id_fk": { + "name": "file_references_reader_id_readers_id_fk", + "tableFrom": "file_references", + "tableTo": "readers", + "columnsFrom": [ + "reader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "links_name_uniq": { + "name": "links_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "links_url_uniq": { + "name": "links_url_uniq", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meta_presets": { + "name": "meta_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "meta_presets_name_uniq": { + "name": "meta_presets_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options": { + "name": "options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "options_name_uniq": { + "name": "options_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_vote_options": { + "name": "poll_vote_options", + "schema": "", + "columns": { + "vote_id": { + "name": "vote_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_vote_options_pk": { + "name": "poll_vote_options_pk", + "columns": [ + { + "expression": "vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_vote_options_option_idx": { + "name": "poll_vote_options_option_idx", + "columns": [ + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_vote_options_vote_id_poll_votes_id_fk": { + "name": "poll_vote_options_vote_id_poll_votes_id_fk", + "tableFrom": "poll_vote_options", + "tableTo": "poll_votes", + "columnsFrom": [ + "vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "poll_id": { + "name": "poll_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voter_fingerprint": { + "name": "voter_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "poll_votes_poll_voter_uniq": { + "name": "poll_votes_poll_voter_uniq", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "voter_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "poll_votes_poll_id_idx": { + "name": "poll_votes_poll_id_idx", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "doc_url": { + "name": "doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_url": { + "name": "project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_name_uniq": { + "name": "projects_name_uniq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.says": { + "name": "says", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "says_created_at_idx": { + "name": "says_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_logs": { + "name": "serverless_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "function_id": { + "name": "function_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_time": { + "name": "execution_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "serverless_logs_created_at_idx": { + "name": "serverless_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_function_idx": { + "name": "serverless_logs_function_idx", + "columns": [ + { + "expression": "function_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "serverless_logs_reference_idx": { + "name": "serverless_logs_reference_idx", + "columns": [ + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverless_storages": { + "name": "serverless_storages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "serverless_storages_ns_key_uniq": { + "name": "serverless_storages_ns_key_uniq", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slug_trackers": { + "name": "slug_trackers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "slug_trackers_type_target_idx": { + "name": "slug_trackers_type_target_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slug_trackers_slug_type_idx": { + "name": "slug_trackers_slug_type_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'root'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metatype": { + "name": "metatype", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_path": { + "name": "custom_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable": { + "name": "enable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "built_in": { + "name": "built_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "compiled_code": { + "name": "compiled_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "snippets_name_reference_idx": { + "name": "snippets_name_reference_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_type_idx": { + "name": "snippets_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "snippets_custom_path_uniq": { + "name": "snippets_custom_path_uniq", + "columns": [ + { + "expression": "custom_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"snippets\".\"custom_path\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscribes": { + "name": "subscribes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_token": { + "name": "cancel_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscribe": { + "name": "subscribe", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "subscribes_email_uniq": { + "name": "subscribes_email_uniq", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscribes_cancel_token_uniq": { + "name": "subscribes_cancel_token_uniq", + "columns": [ + { + "expression": "cancel_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hook_id": { + "name": "hook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "webhook_events_hook_id_idx": { + "name": "webhook_events_hook_id_idx", + "columns": [ + { + "expression": "hook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_timestamp_idx": { + "name": "webhook_events_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_hook_id_webhooks_id_fk": { + "name": "webhook_events_hook_id_webhooks_id_fk", + "tableFrom": "webhook_events", + "tableTo": "webhooks", + "columnsFrom": [ + "hook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_url": { + "name": "payload_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhooks_enabled_idx": { + "name": "webhooks_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_documents": { + "name": "search_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "title_term_freq": { + "name": "title_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "body_term_freq": { + "name": "body_term_freq", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "title_length": { + "name": "title_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "body_length": { + "name": "body_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nid": { + "name": "nid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_at": { + "name": "public_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_password": { + "name": "has_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modified_at": { + "name": "modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "search_documents_ref_lang_uniq": { + "name": "search_documents_ref_lang_uniq", + "columns": [ + { + "expression": "ref_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_published_idx": { + "name": "search_documents_published_idx", + "columns": [ + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "public_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "search_documents_lang_idx": { + "name": "search_documents_lang_idx", + "columns": [ + { + "expression": "lang", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/core/src/database/migrations/meta/_journal.json b/apps/core/src/database/migrations/meta/_journal.json index 2c632745953..3dd667b6a3c 100644 --- a/apps/core/src/database/migrations/meta/_journal.json +++ b/apps/core/src/database/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1779035095334, "tag": "0013_device_codes_table", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1779380000000, + "tag": "0014_enrichment_captures", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/core/src/modules/enrichment/enrichment-screenshot.repository.ts b/apps/core/src/modules/enrichment/enrichment-capture.repository.ts similarity index 65% rename from apps/core/src/modules/enrichment/enrichment-screenshot.repository.ts rename to apps/core/src/modules/enrichment/enrichment-capture.repository.ts index 651a15581f6..bbe68704701 100644 --- a/apps/core/src/modules/enrichment/enrichment-screenshot.repository.ts +++ b/apps/core/src/modules/enrichment/enrichment-capture.repository.ts @@ -4,26 +4,26 @@ import { asc, desc, eq, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { enrichmentCache, - type EnrichmentScreenshotPalette, - enrichmentScreenshots, + type EnrichmentCapturePalette, + enrichmentCaptures, } from '~/database/schema' import type { PaginationResult } from '~/processors/database/base.repository' import { BaseRepository } from '~/processors/database/base.repository' import type { AppDatabase } from '~/processors/database/postgres.provider' -export interface EnrichmentScreenshotRow { +export interface EnrichmentCaptureRow { enrichmentId: string objectKey: string bytes: number width: number height: number blurhash: string | null - palette: EnrichmentScreenshotPalette | null + palette: EnrichmentCapturePalette | null createdAt: Date lastAccessedAt: Date } -export interface EnrichmentScreenshotJoinedRow { +export interface EnrichmentCaptureJoinedRow { enrichmentId: string provider: string externalId: string @@ -34,51 +34,49 @@ export interface EnrichmentScreenshotJoinedRow { width: number height: number blurhash: string | null - palette: EnrichmentScreenshotPalette | null + palette: EnrichmentCapturePalette | null createdAt: Date lastAccessedAt: Date } -export type EnrichmentScreenshotListSort = 'last_accessed' | 'created' | 'bytes' -export type EnrichmentScreenshotListOrder = 'asc' | 'desc' +export type EnrichmentCaptureListSort = 'last_accessed' | 'created' | 'bytes' +export type EnrichmentCaptureListOrder = 'asc' | 'desc' -export interface EnrichmentScreenshotInsert { +export interface EnrichmentCaptureInsert { enrichmentId: string objectKey: string bytes: number width: number height: number blurhash?: string | null - palette?: EnrichmentScreenshotPalette | null + palette?: EnrichmentCapturePalette | null } @Injectable() -export class EnrichmentScreenshotRepository extends BaseRepository { +export class EnrichmentCaptureRepository extends BaseRepository { constructor(@Inject(PG_DB_TOKEN) db: AppDatabase) { super(db) } async findByEnrichmentId( enrichmentId: string, - ): Promise { + ): Promise { const rows = await this.db .select() - .from(enrichmentScreenshots) - .where(eq(enrichmentScreenshots.enrichmentId, enrichmentId)) + .from(enrichmentCaptures) + .where(eq(enrichmentCaptures.enrichmentId, enrichmentId)) .limit(1) return rows[0] ? this.mapRow(rows[0]) : null } /** - * Inserts or replaces the screenshot row for an enrichment. + * Inserts or replaces the capture row for an enrichment. * `last_accessed_at` is set to `now()` on both insert and update — callers * do not need to follow with `touchAccess()`. */ - async upsert( - input: EnrichmentScreenshotInsert, - ): Promise { + async upsert(input: EnrichmentCaptureInsert): Promise { const [row] = await this.db - .insert(enrichmentScreenshots) + .insert(enrichmentCaptures) .values({ enrichmentId: input.enrichmentId, objectKey: input.objectKey, @@ -89,7 +87,7 @@ export class EnrichmentScreenshotRepository extends BaseRepository { palette: input.palette ?? null, }) .onConflictDoUpdate({ - target: enrichmentScreenshots.enrichmentId, + target: enrichmentCaptures.enrichmentId, set: { objectKey: sql`excluded.object_key`, bytes: sql`excluded.bytes`, @@ -106,8 +104,8 @@ export class EnrichmentScreenshotRepository extends BaseRepository { async deleteByEnrichmentId(enrichmentId: string): Promise { await this.db - .delete(enrichmentScreenshots) - .where(eq(enrichmentScreenshots.enrichmentId, enrichmentId)) + .delete(enrichmentCaptures) + .where(eq(enrichmentCaptures.enrichmentId, enrichmentId)) } /** @@ -116,48 +114,48 @@ export class EnrichmentScreenshotRepository extends BaseRepository { */ async touchAccess(enrichmentId: string): Promise { await this.db - .update(enrichmentScreenshots) + .update(enrichmentCaptures) .set({ lastAccessedAt: new Date() }) - .where(eq(enrichmentScreenshots.enrichmentId, enrichmentId)) + .where(eq(enrichmentCaptures.enrichmentId, enrichmentId)) } async listJoined( page: number, size: number, - sort: EnrichmentScreenshotListSort, - order: EnrichmentScreenshotListOrder, - ): Promise> { + sort: EnrichmentCaptureListSort, + order: EnrichmentCaptureListOrder, + ): Promise> { const offset = (page - 1) * size const sortColumn = sort === 'created' - ? enrichmentScreenshots.createdAt + ? enrichmentCaptures.createdAt : sort === 'bytes' - ? enrichmentScreenshots.bytes - : enrichmentScreenshots.lastAccessedAt + ? enrichmentCaptures.bytes + : enrichmentCaptures.lastAccessedAt const orderBy = order === 'asc' ? asc(sortColumn) : desc(sortColumn) const rows = await this.db .select({ - enrichmentId: enrichmentScreenshots.enrichmentId, + enrichmentId: enrichmentCaptures.enrichmentId, provider: enrichmentCache.provider, externalId: enrichmentCache.externalId, url: enrichmentCache.url, title: sql`${enrichmentCache.normalized}->>'title'`.as( 'title', ), - objectKey: enrichmentScreenshots.objectKey, - bytes: enrichmentScreenshots.bytes, - width: enrichmentScreenshots.width, - height: enrichmentScreenshots.height, - blurhash: enrichmentScreenshots.blurhash, - palette: enrichmentScreenshots.palette, - createdAt: enrichmentScreenshots.createdAt, - lastAccessedAt: enrichmentScreenshots.lastAccessedAt, + objectKey: enrichmentCaptures.objectKey, + bytes: enrichmentCaptures.bytes, + width: enrichmentCaptures.width, + height: enrichmentCaptures.height, + blurhash: enrichmentCaptures.blurhash, + palette: enrichmentCaptures.palette, + createdAt: enrichmentCaptures.createdAt, + lastAccessedAt: enrichmentCaptures.lastAccessedAt, }) - .from(enrichmentScreenshots) + .from(enrichmentCaptures) .leftJoin( enrichmentCache, - eq(enrichmentScreenshots.enrichmentId, enrichmentCache.id), + eq(enrichmentCaptures.enrichmentId, enrichmentCache.id), ) .orderBy(orderBy) .limit(size) @@ -165,7 +163,7 @@ export class EnrichmentScreenshotRepository extends BaseRepository { const countResult = await this.db .select({ count: sql`count(*)` }) - .from(enrichmentScreenshots) + .from(enrichmentCaptures) const total = Number(countResult[0]?.count ?? 0) return { @@ -192,9 +190,9 @@ export class EnrichmentScreenshotRepository extends BaseRepository { const [row] = await this.db .select({ count: sql`count(*)`, - totalBytes: sql`coalesce(sum(${enrichmentScreenshots.bytes}), 0)`, + totalBytes: sql`coalesce(sum(${enrichmentCaptures.bytes}), 0)`, }) - .from(enrichmentScreenshots) + .from(enrichmentCaptures) return { count: Number(row?.count ?? 0), totalBytes: Number(row?.totalBytes ?? 0), @@ -204,18 +202,18 @@ export class EnrichmentScreenshotRepository extends BaseRepository { /** * Return the oldest-accessed rows for LRU eviction. */ - async findOldestByAccess(limit: number): Promise { + async findOldestByAccess(limit: number): Promise { const rows = await this.db .select() - .from(enrichmentScreenshots) - .orderBy(asc(enrichmentScreenshots.lastAccessedAt)) + .from(enrichmentCaptures) + .orderBy(asc(enrichmentCaptures.lastAccessedAt)) .limit(limit) return rows.map((r) => this.mapRow(r)) } private mapRow( - row: typeof enrichmentScreenshots.$inferSelect, - ): EnrichmentScreenshotRow { + row: typeof enrichmentCaptures.$inferSelect, + ): EnrichmentCaptureRow { return { enrichmentId: row.enrichmentId, objectKey: row.objectKey, diff --git a/apps/core/src/modules/enrichment/enrichment.controller.ts b/apps/core/src/modules/enrichment/enrichment.controller.ts index 5bf9ee8af64..b5b5b24dc87 100644 --- a/apps/core/src/modules/enrichment/enrichment.controller.ts +++ b/apps/core/src/modules/enrichment/enrichment.controller.ts @@ -23,31 +23,31 @@ import { ConfigsService } from '~/modules/configs/configs.service' import { EnrichmentRepository } from './enrichment.repository' import { + AdminCaptureListQueryDto, AdminListQueryDto, AdminProbeBodyDto, - AdminScreenshotListQueryDto, ResolveQueryDto, } from './enrichment.schema' import { EnrichmentService } from './enrichment.service' import type { EnrichmentResult, ProviderMeta } from './enrichment.types' import { ProviderDisabledError, TokenMissingError } from './enrichment.types' +import { EnrichmentCaptureRepository } from './enrichment-capture.repository' import { EnrichmentOriginGuard } from './enrichment-origin.guard' -import { EnrichmentScreenshotRepository } from './enrichment-screenshot.repository' -import { ScreenshotStorageService } from './providers/open-graph/screenshot-storage.service' +import { CaptureStorageService } from './providers/open-graph/capture-storage.service' const PUBLIC_RESOLVE_THROTTLE = { default: { limit: 30, ttl: 60_000 } } const ADMIN_PROBE_THROTTLE = { default: { limit: 30, ttl: 60_000 } } -const DEFAULT_SCREENSHOT_MAX_ITEMS = 500 -const DEFAULT_SCREENSHOT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 +const DEFAULT_CAPTURE_MAX_ITEMS = 500 +const DEFAULT_CAPTURE_MAX_TOTAL_BYTES = 100 * 1024 * 1024 @ApiController('enrichment') export class EnrichmentController { constructor( private readonly enrichmentService: EnrichmentService, - private readonly screenshotStorage: ScreenshotStorageService, + private readonly captureStorage: CaptureStorageService, private readonly enrichmentRepository: EnrichmentRepository, - private readonly screenshotRepository: EnrichmentScreenshotRepository, + private readonly captureRepository: EnrichmentCaptureRepository, private readonly configsService: ConfigsService, ) {} @@ -70,7 +70,7 @@ export class EnrichmentController { // `@Res({ passthrough: true })` adapter object. res.header('X-Enrichment-Stale', 'true') } - this.bumpScreenshotAccess(result) + this.bumpCaptureAccess(result) return result } catch (error) { // Provider not configured / token missing is a "no data" case, not an @@ -97,18 +97,18 @@ export class EnrichmentController { ): Promise { const id = decodeURIComponent((req.params as Record)['*']) const result = await this.enrichmentService.getOne(provider, id, lang) - this.bumpScreenshotAccess(result) + this.bumpCaptureAccess(result) return result } /** * Fire-and-forget LRU touch. The throttle (Redis NX-EX 3600s) lives inside * the storage service, so hot URLs do not write per-request. Failure is - * swallowed to keep the hot path free of screenshot-storage faults. + * swallowed to keep the hot path free of capture-storage faults. */ - private bumpScreenshotAccess(result: EnrichmentResult | undefined): void { - if (!result?.screenshot || !result.id) return - this.screenshotStorage.touchAccess(result.id).catch(() => { + private bumpCaptureAccess(result: EnrichmentResult | undefined): void { + if (!result?.captureImage || !result.id) return + this.captureStorage.touchAccess(result.id).catch(() => { // ignored — storage logs internally }) } @@ -157,23 +157,23 @@ export class EnrichmentController { async byId(@Param('id') id: string) { const row = await this.enrichmentRepository.findById(id) if (!row) throw new NotFoundException(`Enrichment ${id} not found`) - const screenshot = await this.screenshotRepository.findByEnrichmentId(id) - return { ...row, screenshot } + const capture = await this.captureRepository.findByEnrichmentId(id) + return { ...row, capture } } - @Get('admin/screenshots/quota') + @Get('admin/captures/quota') @Auth() - async screenshotQuota() { - const used = await this.screenshotRepository.getQuotaUsage() + async captureQuota() { + const used = await this.captureRepository.getQuotaUsage() const config = await this.configsService.get('thirdPartyServiceIntegration') const openGraph = config?.openGraph const screenshot = openGraph?.screenshot return { used, cap: { - maxItems: Number(screenshot?.maxItems ?? DEFAULT_SCREENSHOT_MAX_ITEMS), + maxItems: Number(screenshot?.maxItems ?? DEFAULT_CAPTURE_MAX_ITEMS), maxTotalBytes: Number( - screenshot?.maxTotalBytes ?? DEFAULT_SCREENSHOT_MAX_TOTAL_BYTES, + screenshot?.maxTotalBytes ?? DEFAULT_CAPTURE_MAX_TOTAL_BYTES, ), }, enabled: screenshot?.enabled === true, @@ -181,10 +181,10 @@ export class EnrichmentController { } } - @Get('admin/screenshots') + @Get('admin/captures') @Auth() - async listScreenshots(@Query() query: AdminScreenshotListQueryDto) { - const result = await this.screenshotRepository.listJoined( + async listCaptures(@Query() query: AdminCaptureListQueryDto) { + const result = await this.captureRepository.listJoined( query.page, query.size, query.sort, @@ -199,22 +199,22 @@ export class EnrichmentController { return { data, pagination: result.pagination } } - @Delete('admin/screenshots/:enrichmentId') + @Delete('admin/captures/:enrichmentId') @Auth() @HttpCode(204) - async deleteScreenshot( + async deleteCapture( @Param('enrichmentId') enrichmentId: string, ): Promise { - await this.screenshotStorage.delete(enrichmentId) - await this.enrichmentRepository.clearScreenshot(enrichmentId) + await this.captureStorage.delete(enrichmentId) + await this.enrichmentRepository.clearCapture(enrichmentId) } - @Post('admin/screenshots/:enrichmentId/recapture') + @Post('admin/captures/:enrichmentId/recapture') @Auth() @HttpCode(200) - async recaptureScreenshot( + async recapture( @Param('enrichmentId') enrichmentId: string, - ): Promise { + ): Promise { const row = await this.enrichmentRepository.findById(enrichmentId) if (!row) throw new NotFoundException(`Enrichment ${enrichmentId} not found`) @@ -244,14 +244,14 @@ export class EnrichmentController { ) const fresh = await this.enrichmentRepository.findById(enrichmentId) - const screenshot = fresh?.normalized.screenshot - if (!screenshot) { + const captureImage = fresh?.normalized.captureImage + if (!captureImage) { throw new UnprocessableEntityException({ code: 'capture_failed', - message: 'Screenshot was not produced by the refresh', + message: 'Capture was not produced by the refresh', }) } - return screenshot + return captureImage } @Post('admin/probe') @@ -264,7 +264,7 @@ export class EnrichmentController { private async resolvePublicUrl(objectKey: string): Promise { try { - return await this.screenshotStorage.getPublicUrlFor(objectKey) + return await this.captureStorage.getPublicUrlFor(objectKey) } catch { return '' } diff --git a/apps/core/src/modules/enrichment/enrichment.module.ts b/apps/core/src/modules/enrichment/enrichment.module.ts index 76810397231..e4aee16413d 100644 --- a/apps/core/src/modules/enrichment/enrichment.module.ts +++ b/apps/core/src/modules/enrichment/enrichment.module.ts @@ -5,8 +5,8 @@ import { ConfigsModule } from '../configs/configs.module' import { EnrichmentController } from './enrichment.controller' import { EnrichmentRepository } from './enrichment.repository' import { EnrichmentService } from './enrichment.service' +import { EnrichmentCaptureRepository } from './enrichment-capture.repository' import { EnrichmentOriginGuard } from './enrichment-origin.guard' -import { EnrichmentScreenshotRepository } from './enrichment-screenshot.repository' import { ArxivProvider } from './providers/arxiv/arxiv.provider' import { BangumiProvider } from './providers/bangumi/bangumi.provider' // Providers @@ -21,9 +21,9 @@ import { NeoDBBookProvider } from './providers/neodb/neodb-book.provider' import { NeteaseMusicProvider } from './providers/netease/netease-music.provider' import { BrowserFetchService } from './providers/open-graph/browser-fetch.service' import { BrowserSessionPool } from './providers/open-graph/browser-session-pool' +import { CapturePipelineService } from './providers/open-graph/capture-pipeline.service' +import { CaptureStorageService } from './providers/open-graph/capture-storage.service' import { OpenGraphProvider } from './providers/open-graph/open-graph.provider' -import { ScreenshotPipelineService } from './providers/open-graph/screenshot-pipeline.service' -import { ScreenshotStorageService } from './providers/open-graph/screenshot-storage.service' import type { EnrichmentProvider } from './providers/provider.interface' import { ProviderRegistry } from './providers/provider.registry' import { QQMusicProvider } from './providers/qq/qq-music.provider' @@ -60,15 +60,15 @@ const allProviders = [ controllers: [EnrichmentController], providers: [ EnrichmentRepository, - EnrichmentScreenshotRepository, + EnrichmentCaptureRepository, EnrichmentService, ProviderRegistry, UrlExtractorService, EnrichmentOriginGuard, BrowserSessionPool, BrowserFetchService, - ScreenshotPipelineService, - ScreenshotStorageService, + CapturePipelineService, + CaptureStorageService, ...allProviders, ], exports: [EnrichmentService, UrlExtractorService], diff --git a/apps/core/src/modules/enrichment/enrichment.repository.ts b/apps/core/src/modules/enrichment/enrichment.repository.ts index 9809cf84600..21f654115f9 100644 --- a/apps/core/src/modules/enrichment/enrichment.repository.ts +++ b/apps/core/src/modules/enrichment/enrichment.repository.ts @@ -8,9 +8,9 @@ import type { AppDatabase } from '~/processors/database/postgres.provider' import { SnowflakeService } from '~/shared/id/snowflake.service' import type { + EnrichmentCapture, EnrichmentResult, EnrichmentRow, - EnrichmentScreenshot, } from './enrichment.types' @Injectable() @@ -158,17 +158,17 @@ export class EnrichmentRepository extends BaseRepository { } /** - * Merge a `screenshot` key into the row's `normalized` JSONB column. Used - * by the post-persist screenshot pipeline so an already-inserted row can - * pick up the screenshot fields without rewriting the entire normalized + * Merge a `captureImage` key into the row's `normalized` JSONB column. Used + * by the post-persist capture pipeline so an already-inserted row can + * pick up the capture fields without rewriting the entire normalized * payload (which would race with concurrent writers and revert other * fields). Uses PostgreSQL's `jsonb` `||` operator for an in-place merge. */ - async updateScreenshot( + async updateCapture( id: string, - screenshot: EnrichmentScreenshot, + captureImage: EnrichmentCapture, ): Promise { - const patch = JSON.stringify({ screenshot }) + const patch = JSON.stringify({ captureImage }) const updated = await this.db .update(enrichmentCache) .set({ @@ -178,20 +178,20 @@ export class EnrichmentRepository extends BaseRepository { .returning({ id: enrichmentCache.id }) if (updated.length === 0) { - // Row vanished between persist and screenshot write — most likely an + // Row vanished between persist and capture write — most likely an // admin `invalidate` ran concurrently. The S3 object is now orphaned; // the warn log is the ops signal for an eventual reconciliation job. this.logger.warn( - `updateScreenshot: row ${id} disappeared between persist and screenshot write; S3 object now orphaned`, + `updateCapture: row ${id} disappeared between persist and capture write; S3 object now orphaned`, ) } } - async clearScreenshot(id: string): Promise { + async clearCapture(id: string): Promise { await this.db .update(enrichmentCache) .set({ - normalized: sql`coalesce(${enrichmentCache.normalized}, '{}'::jsonb) - 'screenshot'`, + normalized: sql`coalesce(${enrichmentCache.normalized}, '{}'::jsonb) - 'captureImage'`, }) .where(eq(enrichmentCache.id, id)) } diff --git a/apps/core/src/modules/enrichment/enrichment.schema.ts b/apps/core/src/modules/enrichment/enrichment.schema.ts index cc96a4abe91..4eaa0c6d5c3 100644 --- a/apps/core/src/modules/enrichment/enrichment.schema.ts +++ b/apps/core/src/modules/enrichment/enrichment.schema.ts @@ -21,14 +21,14 @@ export const AdminListQuerySchema = z.object({ }) export class AdminListQueryDto extends createZodDto(AdminListQuerySchema) {} -export const AdminScreenshotListQuerySchema = z.object({ +export const AdminCaptureListQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), size: z.coerce.number().int().min(1).max(100).default(20), sort: z.enum(['last_accessed', 'created', 'bytes']).default('last_accessed'), order: z.enum(['asc', 'desc']).default('desc'), }) -export class AdminScreenshotListQueryDto extends createZodDto( - AdminScreenshotListQuerySchema, +export class AdminCaptureListQueryDto extends createZodDto( + AdminCaptureListQuerySchema, ) {} export const AdminProbeBodySchema = z.object({ diff --git a/apps/core/src/modules/enrichment/enrichment.service.ts b/apps/core/src/modules/enrichment/enrichment.service.ts index d72abe4871b..b6b8e0ad9ba 100644 --- a/apps/core/src/modules/enrichment/enrichment.service.ts +++ b/apps/core/src/modules/enrichment/enrichment.service.ts @@ -19,8 +19,8 @@ import { TokenMissingError, } from './enrichment.types' import { BrowserFetchService } from './providers/open-graph/browser-fetch.service' -import { ScreenshotPipelineService } from './providers/open-graph/screenshot-pipeline.service' -import { ScreenshotStorageService } from './providers/open-graph/screenshot-storage.service' +import { CapturePipelineService } from './providers/open-graph/capture-pipeline.service' +import { CaptureStorageService } from './providers/open-graph/capture-storage.service' import type { EnrichmentProvider } from './providers/provider.interface' import { ProviderRegistry } from './providers/provider.registry' import type { ContentDoc } from './url-extractor.service' @@ -86,8 +86,8 @@ export class EnrichmentService implements OnModuleInit { private readonly taskQueueProcessor: TaskQueueProcessor, private readonly urlExtractor: UrlExtractorService, private readonly browserFetch: BrowserFetchService, - private readonly screenshotPipeline: ScreenshotPipelineService, - private readonly screenshotStorage: ScreenshotStorageService, + private readonly capturePipeline: CapturePipelineService, + private readonly captureStorage: CaptureStorageService, ) {} onModuleInit() { @@ -295,11 +295,11 @@ export class EnrichmentService implements OnModuleInit { for (const row of rows) { await this.deleteFromRedis(row.url, row.locale) // S3 cleanup runs before the cache row is removed — the FK CASCADE - // would drop the screenshot row but leave the object behind. Failure - // is swallowed (see ScreenshotStorageService.delete contract). - await this.screenshotStorage.delete(row.id).catch((error) => { + // would drop the capture row but leave the object behind. Failure + // is swallowed (see CaptureStorageService.delete contract). + await this.captureStorage.delete(row.id).catch((error) => { this.logger.warn( - `screenshot delete failed for ${row.id}: ${(error as Error).message}`, + `capture delete failed for ${row.id}: ${(error as Error).message}`, ) }) } @@ -318,9 +318,9 @@ export class EnrichmentService implements OnModuleInit { ) if (row) { await this.deleteFromRedis(row.url, cacheLocale) - await this.screenshotStorage.delete(row.id).catch((error) => { + await this.captureStorage.delete(row.id).catch((error) => { this.logger.warn( - `screenshot delete failed for ${row.id}: ${(error as Error).message}`, + `capture delete failed for ${row.id}: ${(error as Error).message}`, ) }) await this.repository.deleteByProviderAndExternalId( @@ -818,23 +818,23 @@ export class EnrichmentService implements OnModuleInit { ) result.id = row.id - await this.processScreenshotIfPresent(row.id, result) + await this.processCaptureIfPresent(row.id, result) return result } /** - * Post-persist screenshot pipeline. Runs only when the matched provider has + * Post-persist capture pipeline. Runs only when the matched provider has * stashed raw screenshot bytes via `BrowserFetchService.attachScreenshotBytes` * (currently only `OpenGraphProvider` in browser mode). The WeakMap read is * the cheapest gate: it returns `undefined` for any provider that did not * attach, so we can call this unconditionally without branching by provider. * * Any failure — pipeline drop, S3 put, DB merge — is logged at `warn` and - * swallowed. The enrichment response is still returned without `screenshot` - * and the card degrades gracefully (existing fallback behavior). + * swallowed. The enrichment response is still returned without + * `captureImage` and the card degrades gracefully. */ - private async processScreenshotIfPresent( + private async processCaptureIfPresent( rowId: string, result: EnrichmentResult, ): Promise { @@ -853,29 +853,29 @@ export class EnrichmentService implements OnModuleInit { screenshotConfig.maxBytesPerImage ?? 512 * 1024, ) - const processed = await this.screenshotPipeline.process(bytes, { + const processed = await this.capturePipeline.process(bytes, { webpQuality, maxBytesPerImage, }) if (!processed) return - const stored = await this.screenshotStorage.storeOrEvict({ + const stored = await this.captureStorage.storeOrEvict({ enrichmentId: rowId, processed, }) - const screenshot = { + const captureImage = { url: stored.url, width: processed.width, height: processed.height, blurhash: processed.blurhash, palette: processed.palette, } - result.screenshot = screenshot - await this.repository.updateScreenshot(rowId, screenshot) + result.captureImage = captureImage + await this.repository.updateCapture(rowId, captureImage) } catch (error) { this.logger.warn( - `screenshot post-persist failed for ${result.url} (rowId=${rowId}): ${ + `capture post-persist failed for ${result.url} (rowId=${rowId}): ${ (error as Error).message }`, ) @@ -885,20 +885,23 @@ export class EnrichmentService implements OnModuleInit { /** * Best-effort enrichment of an EnrichmentResult with image-derived metadata * (dominant accent color, blurhash, dimensions). Mutates `result` in place. - * Skipped when no image URL is set or when `color` is already populated - * (preserves provider-specific writes such as github-repo's language name). + * Skipped when no thumbnailImage URL is set or when `color` is already + * populated (preserves provider-specific writes such as github-repo's + * language name). */ private async enrichWithImageMeta(result: EnrichmentResult): Promise { - if (!result.image?.url) return + if (!result.thumbnailImage?.url) return if (result.color) return try { const { size, accent, blurHash } = - await this.imageService.getOnlineImageSizeAndMeta(result.image.url) + await this.imageService.getOnlineImageSizeAndMeta( + result.thumbnailImage.url, + ) result.color = accent - result.image.blurhash = blurHash - if (size.width != null) result.image.width = size.width - if (size.height != null) result.image.height = size.height + result.thumbnailImage.blurhash = blurHash + if (size.width != null) result.thumbnailImage.width = size.width + if (size.height != null) result.thumbnailImage.height = size.height } catch (error) { this.logger.warn( `Image meta extraction failed for ${result.url}: ${error.message}`, diff --git a/apps/core/src/modules/enrichment/enrichment.types.ts b/apps/core/src/modules/enrichment/enrichment.types.ts index d55851051c3..c4274a8e9ec 100644 --- a/apps/core/src/modules/enrichment/enrichment.types.ts +++ b/apps/core/src/modules/enrichment/enrichment.types.ts @@ -13,23 +13,23 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentScreenshotPalette { +export interface EnrichmentCapturePalette { dominant: string swatches?: string[] } -export interface EnrichmentScreenshot { +export interface EnrichmentCapture { url: string width: number height: number blurhash?: string - palette?: EnrichmentScreenshotPalette + palette?: EnrichmentCapturePalette } export interface EnrichmentResult { /** * Cache row Snowflake id. Populated by `EnrichmentService` on cache hit - * and post-persist paths so consumers (notably the screenshot LRU touch + * and post-persist paths so consumers (notably the capture LRU touch * path) can address the underlying row without re-querying. Absent on * raw provider returns and on results fresh out of the cold provider * fetch before persistence. @@ -38,7 +38,13 @@ export interface EnrichmentResult { title: string description?: string - image?: EnrichmentImage + + /** Square card thumbnail (avatar / poster / icon). */ + thumbnailImage?: EnrichmentImage + + /** Wide 16:9 hero / OG-style preview image. Filled by GitHub providers in T2. */ + previewImage?: EnrichmentImage + url: string category: string @@ -54,12 +60,12 @@ export interface EnrichmentResult { links?: Array<{ rel: string; url: string; label?: string }> /** - * Optional browser-mode page screenshot. Populated by `EnrichmentService` - * after the row is persisted (only when `screenshot.enabled` is set and - * the provider captured raw bytes via `BrowserFetchService.fetchPage`). - * Purely additive — consumers that ignore it see no behavioral change. + * Optional browser-mode page capture (wide puppeteer screenshot). Populated + * by `EnrichmentService` after the row is persisted, only when + * `screenshot.enabled` is set and the provider captured raw bytes via + * `BrowserFetchService.fetchPage`. */ - screenshot?: EnrichmentScreenshot + captureImage?: EnrichmentCapture raw?: TRaw } diff --git a/apps/core/src/modules/enrichment/providers/bangumi/bangumi.provider.ts b/apps/core/src/modules/enrichment/providers/bangumi/bangumi.provider.ts index 40471a4013b..3b9e7ca29b4 100644 --- a/apps/core/src/modules/enrichment/providers/bangumi/bangumi.provider.ts +++ b/apps/core/src/modules/enrichment/providers/bangumi/bangumi.provider.ts @@ -51,7 +51,7 @@ export class BangumiProvider implements EnrichmentProvider { return { title: data.name || data.name_cn || id, description: (data.summary || '').slice(0, 500) || undefined, - image: data.images?.large + thumbnailImage: data.images?.large ? { url: data.images.large, alt: data.name } : undefined, url: `https://bgm.tv/subject/${id}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts index 74c587b6cd4..4a50c7a75be 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts @@ -65,7 +65,7 @@ export class GitHubCommitProvider implements EnrichmentProvider { description: data.commit?.message?.split('\n').slice(1).join('\n').trim() || undefined, - image: data.author?.avatar_url + thumbnailImage: data.author?.avatar_url ? { url: data.author.avatar_url, alt: data.author.login } : undefined, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts index a3d6e5587ad..71bc5e7633a 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts @@ -68,7 +68,7 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { return { title: discussion.title, description: (discussion.body || '').slice(0, 300) || undefined, - image: discussion.author?.avatarUrl + thumbnailImage: discussion.author?.avatarUrl ? { url: discussion.author.avatarUrl, alt: discussion.author.login } : undefined, url: discussion.url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts index 192e7dab901..2e3acf5b31e 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts @@ -76,7 +76,7 @@ export class GitHubIssueProvider implements EnrichmentProvider { return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, - image: data.user?.avatar_url + thumbnailImage: data.user?.avatar_url ? { url: data.user.avatar_url, alt: data.user.login } : undefined, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts index 31c454f2f7b..a1269a577a9 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts @@ -85,7 +85,7 @@ export class GitHubPrProvider implements EnrichmentProvider { return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, - image: data.user?.avatar_url + thumbnailImage: data.user?.avatar_url ? { url: data.user.avatar_url, alt: data.user.login } : undefined, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts index 40dfca549c7..bd38dd1bfe8 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts @@ -66,7 +66,7 @@ export class GitHubRepoProvider implements EnrichmentProvider { return { title: data.full_name || id, description: data.description || undefined, - image: data.owner?.avatar_url + thumbnailImage: data.owner?.avatar_url ? { url: data.owner.avatar_url, alt: `${data.owner.login} avatar` } : undefined, url: data.html_url || `https://github.com/${id}`, diff --git a/apps/core/src/modules/enrichment/providers/neodb/neodb-book.provider.ts b/apps/core/src/modules/enrichment/providers/neodb/neodb-book.provider.ts index 4d07ceef7ee..49c0a589b99 100644 --- a/apps/core/src/modules/enrichment/providers/neodb/neodb-book.provider.ts +++ b/apps/core/src/modules/enrichment/providers/neodb/neodb-book.provider.ts @@ -70,7 +70,7 @@ export class NeoDBBookProvider implements EnrichmentProvider { return { title: data.title || id, description: data.description || undefined, - image: data.cover_image_url + thumbnailImage: data.cover_image_url ? { url: data.cover_image_url, alt: data.title } : undefined, url: data.url || `https://neodb.social/${id}`, diff --git a/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts b/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts index cfd5f85ea36..caad95001bd 100644 --- a/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts +++ b/apps/core/src/modules/enrichment/providers/netease/netease-music.provider.ts @@ -52,7 +52,7 @@ export class NeteaseMusicProvider implements EnrichmentProvider { return { title: song.name || id, description: song.artists?.map((a: any) => a.name).join(', '), - image: song.album?.picUrl + thumbnailImage: song.album?.picUrl ? { url: song.album.picUrl, alt: song.album.name } : undefined, url: `https://music.163.com/#/song?id=${id}`, diff --git a/apps/core/src/modules/enrichment/providers/open-graph/screenshot-pipeline.service.ts b/apps/core/src/modules/enrichment/providers/open-graph/capture-pipeline.service.ts similarity index 91% rename from apps/core/src/modules/enrichment/providers/open-graph/screenshot-pipeline.service.ts rename to apps/core/src/modules/enrichment/providers/open-graph/capture-pipeline.service.ts index 378db22e676..95a29f09b30 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/screenshot-pipeline.service.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/capture-pipeline.service.ts @@ -2,17 +2,17 @@ import { Injectable, Logger } from '@nestjs/common' import { encode } from 'blurhash' import sharp from 'sharp' -export interface ScreenshotPalette { +export interface CapturePalette { dominant: string // #RRGGBB swatches?: string[] // top distinct, optional, up to 3 } -export interface ProcessedScreenshot { +export interface ProcessedCapture { webp: Buffer width: number height: number blurhash: string - palette: ScreenshotPalette + palette: CapturePalette } const MAX_WIDTH = 1280 @@ -34,7 +34,7 @@ const BLURHASH_SIZE = 32 const BLURHASH_COMP_X = 4 const BLURHASH_COMP_Y = 4 -// Single retry, lower quality, before dropping the screenshot entirely. +// Single retry, lower quality, before dropping the capture entirely. const QUALITY_RETRY_STEP = 15 interface ProcessOptions { @@ -43,13 +43,13 @@ interface ProcessOptions { } @Injectable() -export class ScreenshotPipelineService { - private readonly logger = new Logger(ScreenshotPipelineService.name) +export class CapturePipelineService { + private readonly logger = new Logger(CapturePipelineService.name) async process( input: Buffer, opts: ProcessOptions, - ): Promise { + ): Promise { // Reused sharp instance for everything except the swatch/blurhash clones, // which need their own raw pipelines. Note that `.metadata()` on a // pipeline reflects the SOURCE image, not the resized output — so we @@ -64,7 +64,7 @@ export class ScreenshotPipelineService { const dominantHex = rgbToHex(dominant.r, dominant.g, dominant.b) const swatches = await extractSwatches(sharped) - const palette: ScreenshotPalette = swatches.length + const palette: CapturePalette = swatches.length ? { dominant: dominantHex, swatches } : { dominant: dominantHex } @@ -77,7 +77,7 @@ export class ScreenshotPipelineService { ) if (!encoded) { this.logger.warn( - `screenshot pipeline: bytes exceed cap ${opts.maxBytesPerImage} after retry; dropping screenshot`, + `capture pipeline: bytes exceed cap ${opts.maxBytesPerImage} after retry; dropping capture`, ) return null } @@ -86,7 +86,7 @@ export class ScreenshotPipelineService { const width = info.width const height = info.height if (!width || !height) { - this.logger.debug('screenshot pipeline: missing webp output dimensions') + this.logger.debug('capture pipeline: missing webp output dimensions') return null } diff --git a/apps/core/src/modules/enrichment/providers/open-graph/screenshot-storage.service.ts b/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts similarity index 81% rename from apps/core/src/modules/enrichment/providers/open-graph/screenshot-storage.service.ts rename to apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts index ffb69343cfe..52d33f35326 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/screenshot-storage.service.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts @@ -7,16 +7,16 @@ import { getRedisKey } from '~/utils/redis.util' import { S3Uploader } from '~/utils/s3.util' import { EnrichmentRepository } from '../../enrichment.repository' -import { EnrichmentScreenshotRepository } from '../../enrichment-screenshot.repository' -import type { ProcessedScreenshot } from './screenshot-pipeline.service' +import { EnrichmentCaptureRepository } from '../../enrichment-capture.repository' +import type { ProcessedCapture } from './capture-pipeline.service' -export interface ScreenshotStoreResult { +export interface CaptureStoreResult { url: string objectKey: string bytes: number } -const OBJECT_KEY_PREFIX = 'enrichment-screenshots' +const OBJECT_KEY_PREFIX = 'enrichment-captures' const EVICTION_BATCH_LIMIT = 50 const TOUCH_TTL_SECONDS = 3600 @@ -24,7 +24,7 @@ const DEFAULT_MAX_ITEMS = 500 const DEFAULT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 /** - * Owns the put / evict / delete lifecycle for browser-mode screenshot blobs. + * Owns the put / evict / delete lifecycle for browser-mode capture blobs. * * S3 is treated as the source of truth: a DB row is only written after a * successful S3 PUT, and on LRU eviction the S3 DELETE runs before the DB @@ -35,17 +35,15 @@ const DEFAULT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 * The admin "purge enrichment" path must never be blocked by a flaky S3 * round-trip, and a stale `.webp` object that no longer has a DB row is * harmless (no admin UI references it, LRU never visits it). If/when an - * orphan reconciliation job is added, this is the documented entry point; - * for now the trade-off is intentional and Task 3 ships without it. + * orphan reconciliation job is added, this is the documented entry point. * - * `EnrichmentModule` wires this service in Task 4 — this file ships the - * standalone class only. The S3 client is constructed lazily from - * `imageStorageOptions` and cached per-call options signature so live config - * edits (custom domain, credentials) are picked up without a restart. + * The S3 client is constructed lazily from `imageStorageOptions` and cached + * per-call options signature so live config edits (custom domain, + * credentials) are picked up without a restart. */ @Injectable() -export class ScreenshotStorageService { - private readonly logger = new Logger(ScreenshotStorageService.name) +export class CaptureStorageService { + private readonly logger = new Logger(CaptureStorageService.name) private cachedUploader: { signature: string; uploader: S3Uploader } | null = null @@ -61,7 +59,7 @@ export class ScreenshotStorageService { private storeChain: Promise = Promise.resolve() constructor( - private readonly repository: EnrichmentScreenshotRepository, + private readonly repository: EnrichmentCaptureRepository, private readonly enrichmentRepository: EnrichmentRepository, private readonly configsService: ConfigsService, private readonly redisService: RedisService, @@ -69,8 +67,8 @@ export class ScreenshotStorageService { async storeOrEvict(args: { enrichmentId: string - processed: ProcessedScreenshot - }): Promise { + processed: ProcessedCapture + }): Promise { const next = this.storeChain.then(() => this.storeOrEvictUnlocked(args)) // Swallow rejection on the chain itself so a single failed write does // not poison every subsequent caller; each awaiter still sees the real @@ -81,8 +79,8 @@ export class ScreenshotStorageService { private async storeOrEvictUnlocked(args: { enrichmentId: string - processed: ProcessedScreenshot - }): Promise { + processed: ProcessedCapture + }): Promise { const { enrichmentId, processed } = args const bytes = processed.webp.length const objectKey = this.objectKeyFor(enrichmentId) @@ -132,7 +130,7 @@ export class ScreenshotStorageService { await s3.deleteObject(row.objectKey) } catch (error) { this.logger.warn( - `screenshot delete: S3 deleteObject failed for ${row.objectKey}: ${(error as Error).message}`, + `capture delete: S3 deleteObject failed for ${row.objectKey}: ${(error as Error).message}`, ) } @@ -145,7 +143,7 @@ export class ScreenshotStorageService { } async touchAccess(enrichmentId: string): Promise { - const key = getRedisKey(RedisKeys.EnrichmentScreenshotTouch, enrichmentId) + const key = getRedisKey(RedisKeys.EnrichmentCaptureTouch, enrichmentId) let acquired: boolean try { const client = this.redisService.getClient() @@ -155,7 +153,7 @@ export class ScreenshotStorageService { // Best-effort. LRU may drift slightly toward marking active rows as // stale; recoverable on next access. this.logger.debug( - `screenshot touch: Redis NX-EX failed for ${enrichmentId}: ${(error as Error).message}`, + `capture touch: Redis NX-EX failed for ${enrichmentId}: ${(error as Error).message}`, ) return } @@ -166,7 +164,7 @@ export class ScreenshotStorageService { await this.repository.touchAccess(enrichmentId) } catch (error) { this.logger.debug( - `screenshot touch: DB update failed for ${enrichmentId}: ${(error as Error).message}`, + `capture touch: DB update failed for ${enrichmentId}: ${(error as Error).message}`, ) } } @@ -193,14 +191,14 @@ export class ScreenshotStorageService { // cannot possibly help — bail with a clear error rather than spin. if (newBytes > maxTotalBytes) { throw new Error( - `screenshot bytes (${newBytes}) exceed maxTotalBytes (${maxTotalBytes}); refusing to store`, + `capture bytes (${newBytes}) exceed maxTotalBytes (${maxTotalBytes}); refusing to store`, ) } // The row we're about to overwrite (if any) is invariant across the // eviction loop — same enrichmentId, hoisted so we don't re-query it // every iteration. Subtract its byte count from projected usage so an - // upsert that REPLACES an existing screenshot doesn't trigger false- + // upsert that REPLACES an existing capture doesn't trigger false- // positive eviction. const existing = await this.repository.findByEnrichmentId(excludeEnrichmentId) @@ -228,7 +226,7 @@ export class ScreenshotStorageService { // the lone existing row IS the one being replaced (handled above) // or maxItems/maxTotalBytes was lowered below the single new row. throw new Error( - `screenshot store: cannot fit new bytes (${newBytes}) within quota (items=${projectedCount}/${maxItems}, bytes=${projectedBytes}/${maxTotalBytes}) and no rows are available to evict`, + `capture store: cannot fit new bytes (${newBytes}) within quota (items=${projectedCount}/${maxItems}, bytes=${projectedBytes}/${maxTotalBytes}) and no rows are available to evict`, ) } @@ -250,24 +248,24 @@ export class ScreenshotStorageService { // failure is logged and treated as "skip this row" so a single // dead key cannot block the whole eviction batch. this.logger.warn( - `screenshot evict: S3 deleteObject failed for ${row.objectKey}: ${(error as Error).message}; treating as deleted`, + `capture evict: S3 deleteObject failed for ${row.objectKey}: ${(error as Error).message}; treating as deleted`, ) }) } catch (error) { this.logger.warn( - `screenshot evict: unexpected S3 error for ${row.objectKey}: ${(error as Error).message}`, + `capture evict: unexpected S3 error for ${row.objectKey}: ${(error as Error).message}`, ) } try { - await this.enrichmentRepository.clearScreenshot(row.enrichmentId) + await this.enrichmentRepository.clearCapture(row.enrichmentId) await this.repository.deleteByEnrichmentId(row.enrichmentId) } catch (error) { // Abort: leaving the DB row in place while the S3 object is gone // is acceptable (frontend tolerates 404), but proceeding to PUT // now would risk overshooting the quota indefinitely. throw new Error( - `screenshot evict: aborted batch — DB delete failed for ${row.enrichmentId}: ${(error as Error).message}`, + `capture evict: aborted batch — DB delete failed for ${row.enrichmentId}: ${(error as Error).message}`, { cause: error }, ) } @@ -280,7 +278,7 @@ export class ScreenshotStorageService { // All rows in the batch were the excluded row; we cannot make // progress. Surface a clear error. throw new Error( - 'screenshot evict: no evictable rows found in batch (all matched the excluded enrichment id)', + 'capture evict: no evictable rows found in batch (all matched the excluded enrichment id)', ) } @@ -290,7 +288,7 @@ export class ScreenshotStorageService { } throw new Error( - 'screenshot evict: exceeded iteration cap without satisfying quota', + 'capture evict: exceeded iteration cap without satisfying quota', ) } @@ -326,7 +324,7 @@ export class ScreenshotStorageService { !config.bucket ) { throw new Error( - 'screenshot storage: imageStorageOptions is not fully configured (need enable=true, endpoint, secretId, secretKey, bucket)', + 'capture storage: imageStorageOptions is not fully configured (need enable=true, endpoint, secretId, secretKey, bucket)', ) } diff --git a/apps/core/src/modules/enrichment/providers/open-graph/og-parser.ts b/apps/core/src/modules/enrichment/providers/open-graph/og-parser.ts index 704e0266ef3..f3996b9c822 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/og-parser.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/og-parser.ts @@ -117,7 +117,7 @@ export function parseOpenGraph( const result: EnrichmentResult = { title, description, - image: resolvedImage + thumbnailImage: resolvedImage ? { url: resolvedImage.url, alt: imageAlt, @@ -207,10 +207,10 @@ interface ResolvedImage { height?: number } -// `image` is strictly the page's advertised OG / Twitter Card image. Icons -// (favicon, apple-touch-icon) are deliberately excluded — a square favicon -// must never land in a link card's wide image slot. Icons are surfaced -// separately via `result.links`. +// `thumbnailImage` is strictly the page's advertised OG / Twitter Card image. +// Icons (favicon, apple-touch-icon) are deliberately excluded — a square +// favicon must never land in a link card's wide image slot. Icons are +// surfaced separately via `result.links`. function resolveImage(bag: MetaBag, base: string): ResolvedImage | undefined { const candidates = [ pick(bag.og, 'image:secure_url'), diff --git a/apps/core/src/modules/enrichment/providers/open-graph/open-graph.provider.ts b/apps/core/src/modules/enrichment/providers/open-graph/open-graph.provider.ts index 39cbcf7814d..ac8e6ce8c76 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/open-graph.provider.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/open-graph.provider.ts @@ -107,7 +107,7 @@ export class OpenGraphProvider implements EnrichmentProvider { const captureScreenshot = screenshotEnabled ? (html: SafeFetchResult): boolean => { parsedCache = parseOpenGraph(html.body, html.finalUrl, url) - return !parsedCache.result.image?.url + return !parsedCache.result.thumbnailImage?.url } : false const fetched = await this.browserFetch.fetchPage(url, { diff --git a/apps/core/src/modules/enrichment/providers/tmdb/tmdb.provider.ts b/apps/core/src/modules/enrichment/providers/tmdb/tmdb.provider.ts index 59d50105abd..de7e4df8d96 100644 --- a/apps/core/src/modules/enrichment/providers/tmdb/tmdb.provider.ts +++ b/apps/core/src/modules/enrichment/providers/tmdb/tmdb.provider.ts @@ -118,7 +118,7 @@ export class TmdbProvider implements EnrichmentProvider { return { title, description, - image: data.poster_path + thumbnailImage: data.poster_path ? { url: `${TMDB_IMAGE_BASE}${data.poster_path}`, alt: title, diff --git a/apps/core/test/src/database/app-migrations/20260512-enrichment-screenshots.spec.ts b/apps/core/test/src/database/app-migrations/20260512-enrichment-captures.spec.ts similarity index 79% rename from apps/core/test/src/database/app-migrations/20260512-enrichment-screenshots.spec.ts rename to apps/core/test/src/database/app-migrations/20260512-enrichment-captures.spec.ts index f5ee7496bd9..0e81c649def 100644 --- a/apps/core/test/src/database/app-migrations/20260512-enrichment-screenshots.spec.ts +++ b/apps/core/test/src/database/app-migrations/20260512-enrichment-captures.spec.ts @@ -3,31 +3,32 @@ import { type PgTestDatabase, } from 'test/helper/pg-verify-url' -import { enrichmentCache, enrichmentScreenshots } from '~/database/schema' +import { enrichmentCache, enrichmentCaptures } from '~/database/schema' import { SnowflakeGenerator } from '~/shared/id/snowflake.service' /** - * Schema migration test for 0011_enrichment_screenshots. Verifies the new - * table exists with the expected columns/types, the ON DELETE CASCADE link - * to enrichment_cache fires, and the LRU index is present. + * Schema migration test for 0011_enrichment_screenshots + 0014 capture + * rename. Verifies the table exists under its renamed name with the expected + * columns/types, the ON DELETE CASCADE link to enrichment_cache fires, and + * the LRU index is present. */ -describe('migration 0011 — enrichment_screenshots', () => { +describe('migration — enrichment_captures', () => { let context: PgTestDatabase beforeAll(async () => { - context = await createPgTestDatabase('mx_enrichment_screenshots') + context = await createPgTestDatabase('mx_enrichment_captures') }, 60_000) afterAll(async () => { if (context) await context.close() }) - it('creates enrichment_screenshots with the spec column types', async () => { + it('creates enrichment_captures with the spec column types', async () => { const { rows } = await context.pool.query( `select column_name, data_type, is_nullable, column_default from information_schema.columns where table_schema = 'public' - and table_name = 'enrichment_screenshots' + and table_name = 'enrichment_captures' order by ordinal_position`, ) @@ -100,7 +101,7 @@ describe('migration 0011 — enrichment_screenshots', () => { on tc.constraint_name = kcu.constraint_name and tc.table_schema = kcu.table_schema where tc.table_schema = 'public' - and tc.table_name = 'enrichment_screenshots' + and tc.table_name = 'enrichment_captures' and tc.constraint_type = 'PRIMARY KEY'`, ) expect(rows.map((r: any) => r.column_name)).toEqual(['enrichment_id']) @@ -111,14 +112,14 @@ describe('migration 0011 — enrichment_screenshots', () => { `select indexdef from pg_indexes where schemaname = 'public' - and tablename = 'enrichment_screenshots' - and indexname = 'enrichment_screenshots_lru_idx'`, + and tablename = 'enrichment_captures' + and indexname = 'enrichment_captures_lru_idx'`, ) expect(rows).toHaveLength(1) expect(rows[0].indexdef).toMatch(/last_accessed_at/i) }) - it('cascades deletes from enrichment_cache to enrichment_screenshots', async () => { + it('cascades deletes from enrichment_cache to enrichment_captures', async () => { const generator = new SnowflakeGenerator({ workerId: 17 }) const enrichmentId = generator.nextId() @@ -133,7 +134,7 @@ describe('migration 0011 — enrichment_screenshots', () => { const objectKey = `screenshots/${enrichmentId}.webp` const palette = { dominant: '#112233' } - await context.db.insert(enrichmentScreenshots).values({ + await context.db.insert(enrichmentCaptures).values({ enrichmentId, objectKey, bytes: 12345, @@ -144,7 +145,7 @@ describe('migration 0011 — enrichment_screenshots', () => { }) const beforeRows = await context.pool.query( - `select object_key, palette from enrichment_screenshots where enrichment_id = $1`, + `select object_key, palette from enrichment_captures where enrichment_id = $1`, [enrichmentId], ) expect(beforeRows.rowCount).toBe(1) @@ -158,7 +159,7 @@ describe('migration 0011 — enrichment_screenshots', () => { ]) const afterCount = await context.pool.query( - `select count(*)::int as n from enrichment_screenshots where enrichment_id = $1`, + `select count(*)::int as n from enrichment_captures where enrichment_id = $1`, [enrichmentId], ) expect(afterCount.rows[0].n).toBe(0) diff --git a/apps/core/test/src/modules/enrichment/screenshot-pipeline.service.spec.ts b/apps/core/test/src/modules/enrichment/capture-pipeline.service.spec.ts similarity index 92% rename from apps/core/test/src/modules/enrichment/screenshot-pipeline.service.spec.ts rename to apps/core/test/src/modules/enrichment/capture-pipeline.service.spec.ts index c4dce89cac5..98b3500f9a7 100644 --- a/apps/core/test/src/modules/enrichment/screenshot-pipeline.service.spec.ts +++ b/apps/core/test/src/modules/enrichment/capture-pipeline.service.spec.ts @@ -2,7 +2,7 @@ import { decode } from 'blurhash' import sharp from 'sharp' import { beforeAll, describe, expect, it } from 'vitest' -import { ScreenshotPipelineService } from '~/modules/enrichment/providers/open-graph/screenshot-pipeline.service' +import { CapturePipelineService } from '~/modules/enrichment/providers/open-graph/capture-pipeline.service' // Fixture images are synthesized at test setup with `sharp` so no binary // asset needs to live in the repo. The shapes are picked to exercise: @@ -81,20 +81,20 @@ async function makeRandomNoisePng( return sharp(data, { raw: { width, height, channels } }).png().toBuffer() } -describe('ScreenshotPipelineService', () => { - let service: ScreenshotPipelineService +describe('CapturePipelineService', () => { + let service: CapturePipelineService let multiColor1280: Buffer let multiColorLarge: Buffer let noisyLarge: Buffer beforeAll(async () => { - service = new ScreenshotPipelineService() + service = new CapturePipelineService() multiColor1280 = await makeMultiColorPng(1280, 720) multiColorLarge = await makeMultiColorPng(2560, 1440) noisyLarge = await makeRandomNoisePng(1280, 720) }) - it('returns processed screenshot for a normal image', async () => { + it('returns processed capture for a normal image', async () => { const result = await service.process(multiColor1280, { webpQuality: 75, maxBytesPerImage: 1024 * 1024, @@ -148,7 +148,7 @@ describe('ScreenshotPipelineService', () => { it('returns null when even the retry-quality webp exceeds the byte cap', async () => { // 1280x720 of LCG noise will not compress under 100 bytes at q=95 nor - // at the retry q=80, so the pipeline must drop the screenshot. This + // at the retry q=80, so the pipeline must drop the capture. This // is the deterministic null branch and the warn log path. const result = await service.process(noisyLarge, { webpQuality: 95, diff --git a/apps/core/test/src/modules/enrichment/screenshot-storage.service.spec.ts b/apps/core/test/src/modules/enrichment/capture-storage.service.spec.ts similarity index 86% rename from apps/core/test/src/modules/enrichment/screenshot-storage.service.spec.ts rename to apps/core/test/src/modules/enrichment/capture-storage.service.spec.ts index 0448d9c3815..b34e4f0a940 100644 --- a/apps/core/test/src/modules/enrichment/screenshot-storage.service.spec.ts +++ b/apps/core/test/src/modules/enrichment/capture-storage.service.spec.ts @@ -4,11 +4,11 @@ import { type PgTestDatabase, } from 'test/helper/pg-verify-url' -import { enrichmentCache, enrichmentScreenshots } from '~/database/schema' +import { enrichmentCache, enrichmentCaptures } from '~/database/schema' import { EnrichmentRepository } from '~/modules/enrichment/enrichment.repository' -import { EnrichmentScreenshotRepository } from '~/modules/enrichment/enrichment-screenshot.repository' -import type { ProcessedScreenshot } from '~/modules/enrichment/providers/open-graph/screenshot-pipeline.service' -import { ScreenshotStorageService } from '~/modules/enrichment/providers/open-graph/screenshot-storage.service' +import { EnrichmentCaptureRepository } from '~/modules/enrichment/enrichment-capture.repository' +import type { ProcessedCapture } from '~/modules/enrichment/providers/open-graph/capture-pipeline.service' +import { CaptureStorageService } from '~/modules/enrichment/providers/open-graph/capture-storage.service' import { SnowflakeGenerator } from '~/shared/id/snowflake.service' import type { S3Uploader } from '~/utils/s3.util' @@ -63,7 +63,7 @@ class FakeS3Uploader { * Subclass that injects a controllable fake S3 client and lets us swap the * Redis facade per-test without spinning up a real client. */ -class TestScreenshotStorageService extends ScreenshotStorageService { +class TestCaptureStorageService extends CaptureStorageService { public readonly fakeS3 = new FakeS3Uploader() protected override async getUploader(): Promise { @@ -72,8 +72,8 @@ class TestScreenshotStorageService extends ScreenshotStorageService { } function makeProcessed( - overrides: Partial = {}, -): ProcessedScreenshot { + overrides: Partial = {}, +): ProcessedCapture { return { webp: Buffer.alloc(1024, 0xab), width: 1280, @@ -156,12 +156,12 @@ async function seedEnrichment( }) } -async function setCachedScreenshot(ctx: PgTestDatabase, id: string) { +async function setCachedCapture(ctx: PgTestDatabase, id: string) { await ctx.pool.query( - `update enrichment_cache set normalized = jsonb_set(normalized, '{screenshot}', $1::jsonb) where id = $2`, + `update enrichment_cache set normalized = jsonb_set(normalized, '{captureImage}', $1::jsonb) where id = $2`, [ JSON.stringify({ - url: `https://cdn.example.test/enrichment-screenshots/${id}.webp`, + url: `https://cdn.example.test/enrichment-captures/${id}.webp`, width: 1280, height: 720, }), @@ -179,7 +179,7 @@ async function readCachedNormalized(ctx: PgTestDatabase, id: string) { } /** - * Stamp a deterministic `last_accessed_at` on an existing screenshot row. + * Stamp a deterministic `last_accessed_at` on an existing capture row. * Avoids `setTimeout`-based ordering, which is fragile under CI load. */ async function stampAccessTime( @@ -188,22 +188,25 @@ async function stampAccessTime( iso: string, ) { await ctx.pool.query( - `update enrichment_screenshots set last_accessed_at = $1 where enrichment_id = $2`, + `update enrichment_captures set last_accessed_at = $1 where enrichment_id = $2`, [iso, enrichmentId], ) } -describe('ScreenshotStorageService', () => { +describe('CaptureStorageService', () => { let context: PgTestDatabase - let repository: EnrichmentScreenshotRepository + let repository: EnrichmentCaptureRepository let cacheRepository: EnrichmentRepository beforeAll(async () => { - context = await createPgTestDatabase('mx_screenshot_storage') - repository = new EnrichmentScreenshotRepository(context.db as any) - cacheRepository = new EnrichmentRepository(context.db as any, { - nextId: async () => generator.nextId(), - } as any) + context = await createPgTestDatabase('mx_capture_storage') + repository = new EnrichmentCaptureRepository(context.db as any) + cacheRepository = new EnrichmentRepository( + context.db as any, + { + nextId: async () => generator.nextId(), + } as any, + ) }, 60_000) afterAll(async () => { @@ -212,7 +215,7 @@ describe('ScreenshotStorageService', () => { beforeEach(async () => { // Truncate both tables so each test starts on a clean slate (CASCADE - // removes screenshots first via the FK). + // removes captures first via the FK). await context.pool.query('TRUNCATE enrichment_cache CASCADE') }) @@ -227,7 +230,7 @@ describe('ScreenshotStorageService', () => { await seedEnrichment(context, id) const processed = makeProcessed({ webp: Buffer.alloc(2048, 1) }) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -239,14 +242,14 @@ describe('ScreenshotStorageService', () => { processed, }) - expect(result.objectKey).toBe(`enrichment-screenshots/${id}.webp`) + expect(result.objectKey).toBe(`enrichment-captures/${id}.webp`) expect(result.bytes).toBe(2048) expect(result.url).toBe( - `https://cdn.example.test/enrichment-screenshots/${id}.webp`, + `https://cdn.example.test/enrichment-captures/${id}.webp`, ) expect(service.fakeS3.putCalls).toHaveLength(1) expect(service.fakeS3.putCalls[0]).toMatchObject({ - key: `enrichment-screenshots/${id}.webp`, + key: `enrichment-captures/${id}.webp`, contentType: 'image/webp', bytes: 2048, }) @@ -261,7 +264,7 @@ describe('ScreenshotStorageService', () => { const id = generator.nextId() await seedEnrichment(context, id) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -287,7 +290,7 @@ describe('ScreenshotStorageService', () => { expect(service.fakeS3.deleteCalls).toEqual([]) expect(service.fakeS3.putCalls).toHaveLength(2) expect( - service.fakeS3.objects.get(`enrichment-screenshots/${id}.webp`)?.length, + service.fakeS3.objects.get(`enrichment-captures/${id}.webp`)?.length, ).toBe(3072) const row = await repository.findByEnrichmentId(id) @@ -298,12 +301,12 @@ describe('ScreenshotStorageService', () => { }) }) - it('evicts oldest by item-cap before storing a new screenshot', async () => { + it('evicts oldest by item-cap before storing a new capture', async () => { const ids = [generator.nextId(), generator.nextId(), generator.nextId()] for (const id of ids) await seedEnrichment(context, id) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService({ maxItems: 2 }), @@ -316,7 +319,7 @@ describe('ScreenshotStorageService', () => { enrichmentId: ids[0], processed: makeProcessed({ webp: Buffer.alloc(1024, 1) }), }) - await setCachedScreenshot(context, ids[0]) + await setCachedCapture(context, ids[0]) await stampAccessTime(context, ids[0], '2020-01-01T00:00:00Z') await service.storeOrEvict({ enrichmentId: ids[1], @@ -333,7 +336,7 @@ describe('ScreenshotStorageService', () => { }) expect(service.fakeS3.deleteCalls).toEqual([ - `enrichment-screenshots/${ids[0]}.webp`, + `enrichment-captures/${ids[0]}.webp`, ]) expect(await repository.findByEnrichmentId(ids[0])).toBeNull() expect(await readCachedNormalized(context, ids[0])).toEqual({ @@ -350,7 +353,7 @@ describe('ScreenshotStorageService', () => { const { redisService } = makeRedisService() // maxItems plenty large, but bytes cap tight — 3000 bytes total, // each row is 1500 bytes, so the third write needs eviction. - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService({ maxItems: 100, maxTotalBytes: 3000 }), @@ -373,7 +376,7 @@ describe('ScreenshotStorageService', () => { }) expect(service.fakeS3.deleteCalls).toContain( - `enrichment-screenshots/${ids[0]}.webp`, + `enrichment-captures/${ids[0]}.webp`, ) expect(await repository.findByEnrichmentId(ids[0])).toBeNull() expect(await repository.findByEnrichmentId(ids[1])).not.toBeNull() @@ -385,7 +388,7 @@ describe('ScreenshotStorageService', () => { for (const id of ids) await seedEnrichment(context, id) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService({ maxItems: 1 }), @@ -418,7 +421,7 @@ describe('ScreenshotStorageService', () => { // We abort BEFORE the S3 put for the new id, so its object never appears. expect( - service.fakeS3.objects.has(`enrichment-screenshots/${ids[1]}.webp`), + service.fakeS3.objects.has(`enrichment-captures/${ids[1]}.webp`), ).toBe(false) // Restore repository state for subsequent tests. @@ -429,7 +432,7 @@ describe('ScreenshotStorageService', () => { const id = generator.nextId() await seedEnrichment(context, id) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -444,7 +447,7 @@ describe('ScreenshotStorageService', () => { await service.delete(id) expect(service.fakeS3.deleteCalls).toContain( - `enrichment-screenshots/${id}.webp`, + `enrichment-captures/${id}.webp`, ) expect(await repository.findByEnrichmentId(id)).toBeNull() }) @@ -452,7 +455,7 @@ describe('ScreenshotStorageService', () => { it('delete is a no-op when the row is missing', async () => { const id = generator.nextId() const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -467,7 +470,7 @@ describe('ScreenshotStorageService', () => { const id = generator.nextId() await seedEnrichment(context, id) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -480,7 +483,7 @@ describe('ScreenshotStorageService', () => { }) // Force the next S3 delete for this object key to fail. - const objectKey = `enrichment-screenshots/${id}.webp` + const objectKey = `enrichment-captures/${id}.webp` service.fakeS3.deleteFailKeys.add(objectKey) const warnSpy = vi @@ -499,9 +502,9 @@ describe('ScreenshotStorageService', () => { it('touchAccess updates the DB only on the first Redis NX win', async () => { const id = generator.nextId() await seedEnrichment(context, id) - await context.db.insert(enrichmentScreenshots).values({ + await context.db.insert(enrichmentCaptures).values({ enrichmentId: id, - objectKey: `enrichment-screenshots/${id}.webp`, + objectKey: `enrichment-captures/${id}.webp`, bytes: 1024, width: 1280, height: 720, @@ -515,7 +518,7 @@ describe('ScreenshotStorageService', () => { }) const touchSpy = vi.spyOn(repository, 'touchAccess') - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -531,7 +534,7 @@ describe('ScreenshotStorageService', () => { // it contains the enrichment id and the typed segment. expect(setMock).toHaveBeenNthCalledWith( 1, - expect.stringContaining(`enrichment_screenshot_touch:${id}`), + expect.stringContaining(`enrichment_capture_touch:${id}`), '1', 'EX', 3600, @@ -546,7 +549,7 @@ describe('ScreenshotStorageService', () => { const id = generator.nextId() const { redisService, setMock } = makeRedisService({ setThrows: true }) const touchSpy = vi.spyOn(repository, 'touchAccess') - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -569,7 +572,7 @@ describe('ScreenshotStorageService', () => { await seedEnrichment(context, id1) await seedEnrichment(context, id2) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -613,7 +616,7 @@ describe('ScreenshotStorageService', () => { await seedEnrichment(context, id1) await seedEnrichment(context, id2) const { redisService } = makeRedisService() - const service = new TestScreenshotStorageService( + const service = new TestCaptureStorageService( repository, cacheRepository, makeConfigsService(), @@ -633,6 +636,6 @@ describe('ScreenshotStorageService', () => { enrichmentId: id2, processed: makeProcessed(), }) - expect(result.objectKey).toBe(`enrichment-screenshots/${id2}.webp`) + expect(result.objectKey).toBe(`enrichment-captures/${id2}.webp`) }) }) diff --git a/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts b/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts index ffd6b33b2e0..dd85768c9ea 100644 --- a/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts +++ b/apps/core/test/src/modules/enrichment/enrichment-admin.controller.e2e-spec.ts @@ -8,8 +8,8 @@ import { ConfigsService } from '~/modules/configs/configs.service' import { EnrichmentController } from '~/modules/enrichment/enrichment.controller' import { EnrichmentRepository } from '~/modules/enrichment/enrichment.repository' import { EnrichmentService } from '~/modules/enrichment/enrichment.service' -import { EnrichmentScreenshotRepository } from '~/modules/enrichment/enrichment-screenshot.repository' -import { ScreenshotStorageService } from '~/modules/enrichment/providers/open-graph/screenshot-storage.service' +import { EnrichmentCaptureRepository } from '~/modules/enrichment/enrichment-capture.repository' +import { CaptureStorageService } from '~/modules/enrichment/providers/open-graph/capture-storage.service' const baseRow = { id: 'row-1', @@ -31,9 +31,9 @@ const baseRow = { createdAt: new Date('2026-01-01T00:00:00Z'), } -const baseScreenshotRow = { +const baseCaptureRow = { enrichmentId: 'row-1', - objectKey: 'enrichment-screenshots/row-1.webp', + objectKey: 'enrichment-captures/row-1.webp', bytes: 2048, width: 1280, height: 720, @@ -55,16 +55,16 @@ const configState: ConfigState = { const enrichmentRepositoryMock = { findById: vi.fn(), - clearScreenshot: vi.fn(async () => undefined), + clearCapture: vi.fn(async () => undefined), } -const screenshotRepositoryMock = { +const captureRepositoryMock = { findByEnrichmentId: vi.fn(), getQuotaUsage: vi.fn(async () => ({ count: 3, totalBytes: 4096 })), listJoined: vi.fn(), } -const screenshotStorageMock = { +const captureStorageMock = { delete: vi.fn(async () => undefined), touchAccess: vi.fn(async () => undefined), getPublicUrlFor: vi.fn( @@ -107,13 +107,12 @@ const providers = [ useValue: enrichmentRepositoryMock as unknown as EnrichmentRepository, }), defineProvider({ - provide: EnrichmentScreenshotRepository, - useValue: - screenshotRepositoryMock as unknown as EnrichmentScreenshotRepository, + provide: EnrichmentCaptureRepository, + useValue: captureRepositoryMock as unknown as EnrichmentCaptureRepository, }), defineProvider({ - provide: ScreenshotStorageService, - useValue: screenshotStorageMock as unknown as ScreenshotStorageService, + provide: CaptureStorageService, + useValue: captureStorageMock as unknown as CaptureStorageService, }), defineProvider({ provide: ConfigsService, @@ -132,15 +131,13 @@ describe('EnrichmentController admin endpoints (e2e)', () => { configState.fetchMode = 'fetch' configState.screenshotEnabled = false enrichmentRepositoryMock.findById.mockResolvedValue(baseRow) - enrichmentRepositoryMock.clearScreenshot.mockResolvedValue(undefined) - screenshotRepositoryMock.findByEnrichmentId.mockResolvedValue( - baseScreenshotRow, - ) - screenshotRepositoryMock.getQuotaUsage.mockResolvedValue({ + enrichmentRepositoryMock.clearCapture.mockResolvedValue(undefined) + captureRepositoryMock.findByEnrichmentId.mockResolvedValue(baseCaptureRow) + captureRepositoryMock.getQuotaUsage.mockResolvedValue({ count: 3, totalBytes: 4096, }) - screenshotRepositoryMock.listJoined.mockResolvedValue({ + captureRepositoryMock.listJoined.mockResolvedValue({ data: [ { enrichmentId: 'row-1', @@ -148,7 +145,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { externalId: 'og:example', url: 'https://example.com/post', title: 'Hello', - objectKey: 'enrichment-screenshots/row-1.webp', + objectKey: 'enrichment-captures/row-1.webp', bytes: 2048, width: 1280, height: 720, @@ -167,8 +164,8 @@ describe('EnrichmentController admin endpoints (e2e)', () => { hasPrevPage: false, }, }) - screenshotStorageMock.delete.mockResolvedValue(undefined) - screenshotStorageMock.getPublicUrlFor.mockImplementation( + captureStorageMock.delete.mockResolvedValue(undefined) + captureStorageMock.getPublicUrlFor.mockImplementation( async (objectKey: string) => `https://cdn.example.test/${objectKey}`, ) enrichmentServiceMock.refresh.mockResolvedValue(baseRow.normalized as any) @@ -181,12 +178,12 @@ describe('EnrichmentController admin endpoints (e2e)', () => { body?: unknown }> = [ { method: 'GET', url: 'enrichment/admin/by-id/row-1' }, - { method: 'GET', url: 'enrichment/admin/screenshots' }, - { method: 'GET', url: 'enrichment/admin/screenshots/quota' }, - { method: 'DELETE', url: 'enrichment/admin/screenshots/row-1' }, + { method: 'GET', url: 'enrichment/admin/captures' }, + { method: 'GET', url: 'enrichment/admin/captures/quota' }, + { method: 'DELETE', url: 'enrichment/admin/captures/row-1' }, { method: 'POST', - url: 'enrichment/admin/screenshots/row-1/recapture', + url: 'enrichment/admin/captures/row-1/recapture', }, { method: 'POST', @@ -208,7 +205,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { ) }) - test('GET admin/by-id/:id returns row with screenshot', async () => { + test('GET admin/by-id/:id returns row with capture', async () => { const res = await proxy.app.inject({ method: 'GET', url: `${apiRoutePrefix}/enrichment/admin/by-id/row-1`, @@ -217,8 +214,8 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(res.statusCode).toBe(200) const body = res.json() expect(body.id).toBe('row-1') - expect(body.screenshot).toBeTruthy() - expect(body.screenshot.object_key).toBe('enrichment-screenshots/row-1.webp') + expect(body.capture).toBeTruthy() + expect(body.capture.object_key).toBe('enrichment-captures/row-1.webp') }) test('GET admin/by-id/:id 404 when missing', async () => { @@ -231,40 +228,40 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(res.statusCode).toBe(404) }) - test('GET admin/screenshots returns list with public_url', async () => { + test('GET admin/captures returns list with public_url', async () => { const res = await proxy.app.inject({ method: 'GET', - url: `${apiRoutePrefix}/enrichment/admin/screenshots?page=1&size=20&sort=last_accessed&order=desc`, + url: `${apiRoutePrefix}/enrichment/admin/captures?page=1&size=20&sort=last_accessed&order=desc`, headers: authPassHeader, }) expect(res.statusCode).toBe(200) const body = res.json() expect(body.data).toHaveLength(1) expect(body.data[0].public_url).toBe( - 'https://cdn.example.test/enrichment-screenshots/row-1.webp', + 'https://cdn.example.test/enrichment-captures/row-1.webp', ) expect(body.pagination.total).toBe(1) }) - test('GET admin/screenshots returns publicUrl empty when storage unconfigured', async () => { - screenshotStorageMock.getPublicUrlFor.mockRejectedValueOnce( + test('GET admin/captures returns publicUrl empty when storage unconfigured', async () => { + captureStorageMock.getPublicUrlFor.mockRejectedValueOnce( new Error('not configured'), ) const res = await proxy.app.inject({ method: 'GET', - url: `${apiRoutePrefix}/enrichment/admin/screenshots`, + url: `${apiRoutePrefix}/enrichment/admin/captures`, headers: authPassHeader, }) expect(res.statusCode).toBe(200) expect(res.json().data[0].public_url).toBe('') }) - test('GET admin/screenshots/quota reflects config + usage', async () => { + test('GET admin/captures/quota reflects config + usage', async () => { configState.fetchMode = 'browser' configState.screenshotEnabled = true const res = await proxy.app.inject({ method: 'GET', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/quota`, + url: `${apiRoutePrefix}/enrichment/admin/captures/quota`, headers: authPassHeader, }) expect(res.statusCode).toBe(200) @@ -276,25 +273,23 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(body.fetch_mode).toBe('browser') }) - test('DELETE admin/screenshots/:id 204 even when not present', async () => { + test('DELETE admin/captures/:id 204 even when not present', async () => { const res = await proxy.app.inject({ method: 'DELETE', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/row-1`, + url: `${apiRoutePrefix}/enrichment/admin/captures/row-1`, headers: authPassHeader, }) expect(res.statusCode).toBe(204) - expect(screenshotStorageMock.delete).toHaveBeenCalledWith('row-1') - expect(enrichmentRepositoryMock.clearScreenshot).toHaveBeenCalledWith( - 'row-1', - ) + expect(captureStorageMock.delete).toHaveBeenCalledWith('row-1') + expect(enrichmentRepositoryMock.clearCapture).toHaveBeenCalledWith('row-1') }) - test('POST admin/screenshots/:id/recapture 409 when fetchMode != browser', async () => { + test('POST admin/captures/:id/recapture 409 when fetchMode != browser', async () => { configState.fetchMode = 'fetch' configState.screenshotEnabled = true const res = await proxy.app.inject({ method: 'POST', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/row-1/recapture`, + url: `${apiRoutePrefix}/enrichment/admin/captures/row-1/recapture`, headers: authPassHeader, }) expect(res.statusCode).toBe(409) @@ -302,12 +297,12 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(body.message?.code ?? body.code).toBe('browser_mode_required') }) - test('POST admin/screenshots/:id/recapture 409 when screenshot disabled', async () => { + test('POST admin/captures/:id/recapture 409 when screenshot disabled', async () => { configState.fetchMode = 'browser' configState.screenshotEnabled = false const res = await proxy.app.inject({ method: 'POST', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/row-1/recapture`, + url: `${apiRoutePrefix}/enrichment/admin/captures/row-1/recapture`, headers: authPassHeader, }) expect(res.statusCode).toBe(409) @@ -315,21 +310,21 @@ describe('EnrichmentController admin endpoints (e2e)', () => { expect(body.message?.code ?? body.code).toBe('screenshot_disabled') }) - test('POST admin/screenshots/:id/recapture 404 for unknown id', async () => { + test('POST admin/captures/:id/recapture 404 for unknown id', async () => { enrichmentRepositoryMock.findById.mockResolvedValueOnce(null) const res = await proxy.app.inject({ method: 'POST', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/missing/recapture`, + url: `${apiRoutePrefix}/enrichment/admin/captures/missing/recapture`, headers: authPassHeader, }) expect(res.statusCode).toBe(404) }) - test('POST admin/screenshots/:id/recapture happy path returns screenshot', async () => { + test('POST admin/captures/:id/recapture happy path returns captureImage', async () => { configState.fetchMode = 'browser' configState.screenshotEnabled = true - const screenshot = { - url: 'https://cdn.example.test/enrichment-screenshots/row-1.webp', + const captureImage = { + url: 'https://cdn.example.test/enrichment-captures/row-1.webp', width: 1280, height: 720, blurhash: 'L_X', @@ -338,12 +333,12 @@ describe('EnrichmentController admin endpoints (e2e)', () => { .mockResolvedValueOnce(baseRow) .mockResolvedValueOnce({ ...baseRow, - normalized: { ...baseRow.normalized, screenshot } as any, + normalized: { ...baseRow.normalized, captureImage } as any, }) const res = await proxy.app.inject({ method: 'POST', - url: `${apiRoutePrefix}/enrichment/admin/screenshots/row-1/recapture`, + url: `${apiRoutePrefix}/enrichment/admin/captures/row-1/recapture`, headers: authPassHeader, }) expect(res.statusCode).toBe(200) @@ -354,7 +349,7 @@ describe('EnrichmentController admin endpoints (e2e)', () => { { url: 'https://example.com/post' }, ) const body = res.json() - expect(body.url).toBe(screenshot.url) + expect(body.url).toBe(captureImage.url) }) test('POST admin/probe forwards useCache=true and returns result', async () => { diff --git a/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts b/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts index e4a947702b5..8b5dedbffdc 100644 --- a/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts +++ b/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts @@ -143,8 +143,8 @@ function makeService(stubs: ServiceStubs = {}) { service.taskQueueService = taskQueueService service.taskQueueProcessor = taskQueueProcessor service.browserFetch = browserFetch - service.screenshotPipeline = screenshotPipeline - service.screenshotStorage = screenshotStorage + service.capturePipeline = screenshotPipeline + service.captureStorage = screenshotStorage service.logger = { warn: vi.fn(), log: vi.fn() } return { @@ -207,7 +207,7 @@ describe('EnrichmentService.resolve (SWR)', () => { it('cold path: DB miss → fetch + enrichWithImageMeta + upsert', async () => { const fetchResult = makeResult({ - image: { url: 'https://image.tmdb.org/t/p/w500/poster.jpg' }, + thumbnailImage: { url: 'https://image.tmdb.org/t/p/w500/poster.jpg' }, }) const { service, repository, imageService, provider } = makeService({ dbRow: null, @@ -219,9 +219,9 @@ describe('EnrichmentService.resolve (SWR)', () => { 'https://image.tmdb.org/t/p/w500/poster.jpg', ) expect(out.result.color).toBe('#aabbcc') - expect(out.result.image?.blurhash).toBe('L_blurhash_') - expect(out.result.image?.width).toBe(500) - expect(out.result.image?.height).toBe(750) + expect(out.result.thumbnailImage?.blurhash).toBe('L_blurhash_') + expect(out.result.thumbnailImage?.width).toBe(500) + expect(out.result.thumbnailImage?.height).toBe(750) expect(repository.upsert).toHaveBeenCalled() }) @@ -394,20 +394,20 @@ describe('EnrichmentService.enrichWithImageMeta', () => { it('populates color/blurhash/size when image.url present and color absent', async () => { const { service, imageService } = makeService() const result = makeResult({ - image: { url: 'https://example.com/p.jpg' }, + thumbnailImage: { url: 'https://example.com/p.jpg' }, }) await (service as any).enrichWithImageMeta(result) expect(imageService.getOnlineImageSizeAndMeta).toHaveBeenCalledTimes(1) expect(result.color).toBe('#aabbcc') - expect(result.image?.blurhash).toBe('L_blurhash_') - expect(result.image?.width).toBe(500) - expect(result.image?.height).toBe(750) + expect(result.thumbnailImage?.blurhash).toBe('L_blurhash_') + expect(result.thumbnailImage?.width).toBe(500) + expect(result.thumbnailImage?.height).toBe(750) }) it('skips when color is already set (e.g. github-repo language name)', async () => { const { service, imageService } = makeService() const result = makeResult({ - image: { url: 'https://example.com/p.jpg' }, + thumbnailImage: { url: 'https://example.com/p.jpg' }, color: 'TypeScript', }) await (service as any).enrichWithImageMeta(result) @@ -428,7 +428,7 @@ describe('EnrichmentService.enrichWithImageMeta', () => { imageMeta: new Error('boom'), }) const result = makeResult({ - image: { url: 'https://example.com/p.jpg' }, + thumbnailImage: { url: 'https://example.com/p.jpg' }, }) await expect( (service as any).enrichWithImageMeta(result), diff --git a/apps/core/test/src/modules/enrichment/open-graph-screenshot.integration.spec.ts b/apps/core/test/src/modules/enrichment/open-graph-capture.integration.spec.ts similarity index 81% rename from apps/core/test/src/modules/enrichment/open-graph-screenshot.integration.spec.ts rename to apps/core/test/src/modules/enrichment/open-graph-capture.integration.spec.ts index 566d342849e..2b1d2ce0b4d 100644 --- a/apps/core/test/src/modules/enrichment/open-graph-screenshot.integration.spec.ts +++ b/apps/core/test/src/modules/enrichment/open-graph-capture.integration.spec.ts @@ -16,30 +16,30 @@ import { import { EnrichmentRepository } from '~/modules/enrichment/enrichment.repository' import { EnrichmentService } from '~/modules/enrichment/enrichment.service' -import { EnrichmentScreenshotRepository } from '~/modules/enrichment/enrichment-screenshot.repository' +import { EnrichmentCaptureRepository } from '~/modules/enrichment/enrichment-capture.repository' import { BrowserFetchService } from '~/modules/enrichment/providers/open-graph/browser-fetch.service' import { BrowserSessionPool } from '~/modules/enrichment/providers/open-graph/browser-session-pool' -import { OpenGraphProvider } from '~/modules/enrichment/providers/open-graph/open-graph.provider' import { - type ProcessedScreenshot, - ScreenshotPipelineService, -} from '~/modules/enrichment/providers/open-graph/screenshot-pipeline.service' -import { ScreenshotStorageService } from '~/modules/enrichment/providers/open-graph/screenshot-storage.service' + CapturePipelineService, + type ProcessedCapture, +} from '~/modules/enrichment/providers/open-graph/capture-pipeline.service' +import { CaptureStorageService } from '~/modules/enrichment/providers/open-graph/capture-storage.service' +import { OpenGraphProvider } from '~/modules/enrichment/providers/open-graph/open-graph.provider' import { ProviderRegistry } from '~/modules/enrichment/providers/provider.registry' import { SnowflakeService } from '~/shared/id/snowflake.service' import type { S3Uploader } from '~/utils/s3.util' /** - * Integration coverage for Task 4: the post-persist screenshot pipeline. + * Integration coverage for the post-persist capture pipeline. * * Wires real {@link EnrichmentService} + repositories against a Postgres * testcontainer, with three controllable seams: * * - `BrowserFetchService.fetchPage` → returns synthesized HTML + a tiny PNG * (the open-graph provider feeds the PNG into the WeakMap channel). - * - `ScreenshotStorageService.getUploader` → returns an in-memory fake S3 + * - `CaptureStorageService.getUploader` → returns an in-memory fake S3 * that records put/delete calls and can be made to throw on demand. - * - `ScreenshotPipelineService.process` → swapped per test so we can force + * - `CapturePipelineService.process` → swapped per test so we can force * a `null` (oversize) return without re-engineering image fixtures. * * Covers the happy path, screenshot-disabled, pipeline-null, storage-throws, @@ -77,7 +77,7 @@ class FakeS3Uploader { } } -class TestScreenshotStorageService extends ScreenshotStorageService { +class TestCaptureStorageService extends CaptureStorageService { public readonly fakeS3 = new FakeS3Uploader() protected override async getUploader(): Promise { return this.fakeS3 as unknown as S3Uploader @@ -123,7 +123,7 @@ interface BuildOptions { * `process` returns `null` (oversize / drop). When set to a Buffer, the * underlying real pipeline runs against those bytes. */ - pipelineOverride?: ProcessedScreenshot | null + pipelineOverride?: ProcessedCapture | null /** * Force `storeOrEvict` to throw the given error. */ @@ -141,14 +141,14 @@ interface BuildOptions { htmlBody?: string } -describe('OpenGraph screenshot integration (Task 4)', () => { +describe('OpenGraph capture integration', () => { let context: PgTestDatabase let snowflake: SnowflakeService const RESOLVE_URL = 'https://integration.example.test/post/1' const activePools: BrowserSessionPool[] = [] beforeAll(async () => { - context = await createPgTestDatabase('mx_og_screenshot') + context = await createPgTestDatabase('mx_og_capture') snowflake = new SnowflakeService() }, 60_000) @@ -167,9 +167,7 @@ describe('OpenGraph screenshot integration (Task 4)', () => { async function buildHarness(opts: BuildOptions = {}) { const repository = new EnrichmentRepository(context.db as any, snowflake) - const screenshotRepository = new EnrichmentScreenshotRepository( - context.db as any, - ) + const captureRepository = new EnrichmentCaptureRepository(context.db as any) const screenshot = { enabled: true, @@ -259,7 +257,7 @@ describe('OpenGraph screenshot integration (Task 4)', () => { } }) - const pipeline = new ScreenshotPipelineService() + const pipeline = new CapturePipelineService() const processSpy = vi.spyOn(pipeline, 'process') if (opts.pipelineOverride === null) { processSpy.mockResolvedValue(null) @@ -267,8 +265,8 @@ describe('OpenGraph screenshot integration (Task 4)', () => { processSpy.mockResolvedValue(opts.pipelineOverride) } - const storage = new TestScreenshotStorageService( - screenshotRepository, + const storage = new TestCaptureStorageService( + captureRepository, repository, configsService, redisService, @@ -309,35 +307,33 @@ describe('OpenGraph screenshot integration (Task 4)', () => { browserFetch, fetchPageSpy, repository, - screenshotRepository, + captureRepository, touchSpy, fakeS3: storage.fakeS3, } } - it('happy path: persists row, runs pipeline, attaches screenshot to response', async () => { + it('happy path: persists row, runs pipeline, attaches capture to response', async () => { const { service, fakeS3 } = await buildHarness() const { result } = await service.resolve(RESOLVE_URL) - expect(result.screenshot).toBeDefined() - expect(result.screenshot?.url).toMatch(/^https:\/\/cdn\.example\.test\//) - expect(result.screenshot?.width).toBeGreaterThan(0) - expect(result.screenshot?.height).toBeGreaterThan(0) - expect(result.screenshot?.palette?.dominant).toMatch(/^#[\da-f]{6}$/i) + expect(result.captureImage).toBeDefined() + expect(result.captureImage?.url).toMatch(/^https:\/\/cdn\.example\.test\//) + expect(result.captureImage?.width).toBeGreaterThan(0) + expect(result.captureImage?.height).toBeGreaterThan(0) + expect(result.captureImage?.palette?.dominant).toMatch(/^#[\da-f]{6}$/i) expect(fakeS3.putCalls).toHaveLength(1) - expect(fakeS3.putCalls[0].key).toMatch( - /^enrichment-screenshots\/\d+\.webp$/, - ) + expect(fakeS3.putCalls[0].key).toMatch(/^enrichment-captures\/\d+\.webp$/) - // The row's normalized JSON must include the screenshot key — second + // The row's normalized JSON must include the captureImage key — second // resolve (cache hit) should see it without re-running the pipeline. const second = await service.resolve(RESOLVE_URL) - expect(second.result.screenshot?.url).toBe(result.screenshot?.url) + expect(second.result.captureImage?.url).toBe(result.captureImage?.url) expect(fakeS3.putCalls).toHaveLength(1) }) - it('og:image present: screenshot fallback is skipped even when enabled', async () => { + it('og:image present: capture fallback is skipped even when enabled', async () => { const htmlBody = ` has image @@ -349,8 +345,8 @@ describe('OpenGraph screenshot integration (Task 4)', () => { }) const { result } = await service.resolve(RESOLVE_URL) - expect(result.image?.url).toBe('https://cdn.example.test/og.png') - expect(result.screenshot).toBeUndefined() + expect(result.thumbnailImage?.url).toBe('https://cdn.example.test/og.png') + expect(result.captureImage).toBeUndefined() // Provider passed a predicate (function), not a literal — predicate // returned false because og:image was present. expect(fetchPageSpy).toHaveBeenCalledWith( @@ -363,13 +359,13 @@ describe('OpenGraph screenshot integration (Task 4)', () => { expect(fakeS3.putCalls).toHaveLength(0) }) - it('screenshot disabled: browser metadata fetch does not capture or store bytes', async () => { + it('capture disabled: browser metadata fetch does not capture or store bytes', async () => { const { service, fakeS3, fetchPageSpy, processSpy } = await buildHarness({ screenshot: { enabled: false }, }) const { result } = await service.resolve(RESOLVE_URL) - expect(result.screenshot).toBeUndefined() + expect(result.captureImage).toBeUndefined() expect(fetchPageSpy).toHaveBeenCalledWith( RESOLVE_URL, expect.objectContaining({ captureScreenshot: false }), @@ -378,41 +374,41 @@ describe('OpenGraph screenshot integration (Task 4)', () => { expect(fakeS3.putCalls).toHaveLength(0) }) - it('pipeline returns null (oversize): response has no screenshot field', async () => { + it('pipeline returns null (oversize): response has no captureImage field', async () => { const { service, fakeS3 } = await buildHarness({ pipelineOverride: null, }) const { result } = await service.resolve(RESOLVE_URL) - expect(result.screenshot).toBeUndefined() + expect(result.captureImage).toBeUndefined() expect(fakeS3.putCalls).toHaveLength(0) }) - it('storage throws: response is still returned without screenshot', async () => { + it('storage throws: response is still returned without captureImage', async () => { const { service, fakeS3 } = await buildHarness({ storageError: new Error('simulated S3 failure'), }) const { result } = await service.resolve(RESOLVE_URL) - expect(result.screenshot).toBeUndefined() + expect(result.captureImage).toBeUndefined() // Storage stub throws synchronously before S3 PUT, so the fake never sees // the put call. expect(fakeS3.putCalls).toHaveLength(0) }) - it('cache hit: touchAccess fires for results that carry a screenshot', async () => { + it('cache hit: touchAccess fires for results that carry a captureImage', async () => { const { service, storage, touchSpy } = await buildHarness() // Cold path warms the row. const cold = await service.resolve(RESOLVE_URL) - expect(cold.result.screenshot).toBeDefined() + expect(cold.result.captureImage).toBeDefined() expect(cold.result.id).toBeDefined() // Simulate the controller's fire-and-forget call on a cache-hit response. // We do not import the controller here to keep this test scoped to the // service-level integration; the controller code path is mechanically the - // same: `if (result.screenshot && result.id) storage.touchAccess(result.id)`. - if (cold.result.screenshot && cold.result.id) { + // same: `if (result.captureImage && result.id) storage.touchAccess(result.id)`. + if (cold.result.captureImage && cold.result.id) { await storage.touchAccess(cold.result.id) } diff --git a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts index c4d5f009fe1..076e452c455 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts @@ -55,7 +55,10 @@ describe('GitHubDiscussionProvider', () => { expect(result.title).toBe('Feature Request') expect(result.subtype).toBe('discussion') expect(result.url).toBe('https://github.com/mx-space/core/discussions/42') - expect(result.image).toEqual({ url: 'https://avatar', alt: 'user' }) + expect(result.thumbnailImage).toEqual({ + url: 'https://avatar', + alt: 'user', + }) expect(result.attributes).toContainEqual({ key: 'repo', value: 'mx-space/core', @@ -99,7 +102,7 @@ describe('GitHubDiscussionProvider', () => { const result = await p.fetch('mx-space/core/discussions/1') - expect(result.image).toBeUndefined() + expect(result.thumbnailImage).toBeUndefined() expect(result.attributes).toContainEqual({ key: 'author', value: '', diff --git a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts index e8e1e960815..31359718f91 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts @@ -83,7 +83,7 @@ describe('GitHubRepoProvider', () => { expect(result.category).toBe('github') expect(result.subtype).toBe('repo') expect(result.description).toBe('Test description') - expect(result.image).toEqual({ + expect(result.thumbnailImage).toEqual({ url: 'https://avatar.url', alt: 'mx-space avatar', }) @@ -135,7 +135,7 @@ describe('GitHubRepoProvider', () => { expect(result.title).toBe('mx-space/core') expect(result.description).toBeUndefined() - expect(result.image).toBeUndefined() + expect(result.thumbnailImage).toBeUndefined() expect(result.color).toBeUndefined() expect(result.attributes).toEqual([]) }) diff --git a/apps/core/test/src/modules/enrichment/providers/open-graph.parser.spec.ts b/apps/core/test/src/modules/enrichment/providers/open-graph.parser.spec.ts index c594190ccee..1258f9458c0 100644 --- a/apps/core/test/src/modules/enrichment/providers/open-graph.parser.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/open-graph.parser.spec.ts @@ -10,7 +10,7 @@ function parse(head: string, url = 'https://example.com/article') { ) } -describe('parseOpenGraph — image', () => { +describe('parseOpenGraph — thumbnailImage', () => { it('resolves og:image and records advertised dimensions', () => { const { result } = parse(` @@ -18,7 +18,7 @@ describe('parseOpenGraph — image', () => { `) - expect(result.image).toEqual({ + expect(result.thumbnailImage).toEqual({ url: 'https://cdn.example.com/og.png', alt: undefined, width: 1200, @@ -26,13 +26,13 @@ describe('parseOpenGraph — image', () => { }) }) - it('keeps the image without dimensions when none are advertised', () => { + it('keeps the thumbnailImage without dimensions when none are advertised', () => { const { result } = parse(` `) - expect(result.image?.url).toBe('https://cdn.example.com/og.png') - expect(result.image?.width).toBeUndefined() - expect(result.image?.height).toBeUndefined() + expect(result.thumbnailImage?.url).toBe('https://cdn.example.com/og.png') + expect(result.thumbnailImage?.width).toBeUndefined() + expect(result.thumbnailImage?.height).toBeUndefined() }) it('ignores non-numeric / non-positive image dimensions', () => { @@ -41,31 +41,31 @@ describe('parseOpenGraph — image', () => { `) - expect(result.image?.width).toBeUndefined() - expect(result.image?.height).toBeUndefined() + expect(result.thumbnailImage?.width).toBeUndefined() + expect(result.thumbnailImage?.height).toBeUndefined() }) it('falls back to twitter:image when no og:image is present', () => { const { result } = parse(` `) - expect(result.image?.url).toBe('https://cdn.example.com/tw.png') + expect(result.thumbnailImage?.url).toBe('https://cdn.example.com/tw.png') }) - it('absolutizes a relative image url', () => { + it('absolutizes a relative thumbnailImage url', () => { const { result } = parse( ``, 'https://example.com/blog/post', ) - expect(result.image?.url).toBe('https://example.com/static/og.png') + expect(result.thumbnailImage?.url).toBe('https://example.com/static/og.png') }) - it('does NOT use an apple-touch-icon / favicon as the image', () => { + it('does NOT use an apple-touch-icon / favicon as the thumbnailImage', () => { const { result } = parse(` `) - expect(result.image).toBeUndefined() + expect(result.thumbnailImage).toBeUndefined() }) }) diff --git a/apps/core/test/src/modules/enrichment/providers/tmdb.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/tmdb.provider.spec.ts index 164e652efa1..57ba3627f02 100644 --- a/apps/core/test/src/modules/enrichment/providers/tmdb.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/tmdb.provider.spec.ts @@ -70,7 +70,7 @@ describe('TmdbProvider', () => { expect(result.title).toBe('Fight Club') expect(result.subtype).toBe('movie') - expect(result.image?.url).toBe( + expect(result.thumbnailImage?.url).toBe( 'https://image.tmdb.org/t/p/w500/poster.jpg', ) expect(result.publishedAt).toBe('1999-10-15') diff --git a/packages/api-client/models/recently.ts b/packages/api-client/models/recently.ts index 1840f60d839..a8ff025cdcf 100644 --- a/packages/api-client/models/recently.ts +++ b/packages/api-client/models/recently.ts @@ -45,31 +45,26 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentScreenshotPalette { +export interface EnrichmentCapturePalette { dominant: string swatches?: string[] } -export interface EnrichmentScreenshot { +export interface EnrichmentCapture { url: string width: number height: number blurhash?: string - palette?: EnrichmentScreenshotPalette + palette?: EnrichmentCapturePalette } export interface EnrichmentResult { - /** - * Cache row Snowflake id (mx-core enrichment_cache.id). Server-populated on - * cache hits so frontends can address the underlying row (LRU touch, - * admin links). Absent when the result originates from a fresh provider - * fetch that has not yet persisted. - */ id?: string title: string description?: string - image?: EnrichmentImage + thumbnailImage?: EnrichmentImage + previewImage?: EnrichmentImage url: string category: string subtype?: string @@ -78,7 +73,7 @@ export interface EnrichmentResult { attributes?: EnrichmentAttribute[] color?: string links?: Array<{ rel: string; url: string; label?: string }> - screenshot?: EnrichmentScreenshot + captureImage?: EnrichmentCapture } /** diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 8055aa23e1b..bddbdcb940f 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "4.2.0", + "version": "4.3.0", "description": "A api client for mx-space server@next", "type": "module", "engines": { diff --git a/packages/db-schema/src/schema/enrichment.ts b/packages/db-schema/src/schema/enrichment.ts index bdebc84fa5f..c6db7d6cd8f 100644 --- a/packages/db-schema/src/schema/enrichment.ts +++ b/packages/db-schema/src/schema/enrichment.ts @@ -42,13 +42,13 @@ export const enrichmentCache = pgTable( ], ) -export interface EnrichmentScreenshotPalette { +export interface EnrichmentCapturePalette { dominant: string swatches?: string[] } -export const enrichmentScreenshots = pgTable( - 'enrichment_screenshots', +export const enrichmentCaptures = pgTable( + 'enrichment_captures', { enrichmentId: refText('enrichment_id') .primaryKey() @@ -59,7 +59,7 @@ export const enrichmentScreenshots = pgTable( width: integer('width').notNull(), height: integer('height').notNull(), blurhash: text('blurhash'), - palette: jsonb('palette').$type(), + palette: jsonb('palette').$type(), createdAt: createdAt(), lastAccessedAt: timestamp('last_accessed_at', { withTimezone: true, @@ -69,6 +69,6 @@ export const enrichmentScreenshots = pgTable( .defaultNow(), }, (table) => [ - index('enrichment_screenshots_lru_idx').on(table.lastAccessedAt.asc()), + index('enrichment_captures_lru_idx').on(table.lastAccessedAt.asc()), ], ) From 8e7cf4445805e8da0fcc27bfe3d39a416cb0eb75 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:03:01 +0800 Subject: [PATCH 02/10] fix(enrichment): rename missed image field in oembed fallback --- .../src/modules/enrichment/providers/open-graph/oembed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/core/src/modules/enrichment/providers/open-graph/oembed.ts b/apps/core/src/modules/enrichment/providers/open-graph/oembed.ts index e2d8a485329..e518f8dbc71 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/oembed.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/oembed.ts @@ -45,8 +45,8 @@ export async function enrichWithOEmbed( if (!result.title && parsed.title) result.title = parsed.title - if (!result.image?.url && parsed.thumbnail_url) { - result.image = { + if (!result.thumbnailImage?.url && parsed.thumbnail_url) { + result.thumbnailImage = { url: parsed.thumbnail_url, width: parsed.thumbnail_width, height: parsed.thumbnail_height, From bdf108b9a9cfc97211b60be656f993e6b3ca0f7b Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:14:14 +0800 Subject: [PATCH 03/10] refactor(enrichment): cleanup residual screenshot naming and drop redundant JSDoc --- .../modules/enrichment/enrichment.controller.ts | 8 ++++---- .../src/modules/enrichment/enrichment.service.ts | 8 ++++---- .../src/modules/enrichment/enrichment.types.ts | 15 --------------- .../open-graph/capture-storage.service.ts | 6 +++--- .../modules/enrichment/enrichment.service.spec.ts | 12 ++++++------ 5 files changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/core/src/modules/enrichment/enrichment.controller.ts b/apps/core/src/modules/enrichment/enrichment.controller.ts index b5b5b24dc87..16354ce8e41 100644 --- a/apps/core/src/modules/enrichment/enrichment.controller.ts +++ b/apps/core/src/modules/enrichment/enrichment.controller.ts @@ -167,16 +167,16 @@ export class EnrichmentController { const used = await this.captureRepository.getQuotaUsage() const config = await this.configsService.get('thirdPartyServiceIntegration') const openGraph = config?.openGraph - const screenshot = openGraph?.screenshot + const captureConfig = openGraph?.screenshot return { used, cap: { - maxItems: Number(screenshot?.maxItems ?? DEFAULT_CAPTURE_MAX_ITEMS), + maxItems: Number(captureConfig?.maxItems ?? DEFAULT_CAPTURE_MAX_ITEMS), maxTotalBytes: Number( - screenshot?.maxTotalBytes ?? DEFAULT_CAPTURE_MAX_TOTAL_BYTES, + captureConfig?.maxTotalBytes ?? DEFAULT_CAPTURE_MAX_TOTAL_BYTES, ), }, - enabled: screenshot?.enabled === true, + enabled: captureConfig?.enabled === true, fetchMode: openGraph?.fetchMode ?? 'fetch', } } diff --git a/apps/core/src/modules/enrichment/enrichment.service.ts b/apps/core/src/modules/enrichment/enrichment.service.ts index b6b8e0ad9ba..83c727fd218 100644 --- a/apps/core/src/modules/enrichment/enrichment.service.ts +++ b/apps/core/src/modules/enrichment/enrichment.service.ts @@ -845,12 +845,12 @@ export class EnrichmentService implements OnModuleInit { const config = await this.configsService.get( 'thirdPartyServiceIntegration', ) - const screenshotConfig = config.openGraph?.screenshot - if (!screenshotConfig?.enabled) return + const captureConfig = config.openGraph?.screenshot + if (!captureConfig?.enabled) return - const webpQuality = Number(screenshotConfig.webpQuality ?? 75) + const webpQuality = Number(captureConfig.webpQuality ?? 75) const maxBytesPerImage = Number( - screenshotConfig.maxBytesPerImage ?? 512 * 1024, + captureConfig.maxBytesPerImage ?? 512 * 1024, ) const processed = await this.capturePipeline.process(bytes, { diff --git a/apps/core/src/modules/enrichment/enrichment.types.ts b/apps/core/src/modules/enrichment/enrichment.types.ts index c4274a8e9ec..8579e75c0e8 100644 --- a/apps/core/src/modules/enrichment/enrichment.types.ts +++ b/apps/core/src/modules/enrichment/enrichment.types.ts @@ -27,22 +27,13 @@ export interface EnrichmentCapture { } export interface EnrichmentResult { - /** - * Cache row Snowflake id. Populated by `EnrichmentService` on cache hit - * and post-persist paths so consumers (notably the capture LRU touch - * path) can address the underlying row without re-querying. Absent on - * raw provider returns and on results fresh out of the cold provider - * fetch before persistence. - */ id?: string title: string description?: string - /** Square card thumbnail (avatar / poster / icon). */ thumbnailImage?: EnrichmentImage - /** Wide 16:9 hero / OG-style preview image. Filled by GitHub providers in T2. */ previewImage?: EnrichmentImage url: string @@ -59,12 +50,6 @@ export interface EnrichmentResult { links?: Array<{ rel: string; url: string; label?: string }> - /** - * Optional browser-mode page capture (wide puppeteer screenshot). Populated - * by `EnrichmentService` after the row is persisted, only when - * `screenshot.enabled` is set and the provider captured raw bytes via - * `BrowserFetchService.fetchPage`. - */ captureImage?: EnrichmentCapture raw?: TRaw diff --git a/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts b/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts index 52d33f35326..8ab742c8857 100644 --- a/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts +++ b/apps/core/src/modules/enrichment/providers/open-graph/capture-storage.service.ts @@ -297,11 +297,11 @@ export class CaptureStorageService { maxTotalBytes: number }> { const config = await this.configsService.get('thirdPartyServiceIntegration') - const screenshot = config.openGraph?.screenshot + const captureConfig = config.openGraph?.screenshot return { - maxItems: Number(screenshot?.maxItems ?? DEFAULT_MAX_ITEMS), + maxItems: Number(captureConfig?.maxItems ?? DEFAULT_MAX_ITEMS), maxTotalBytes: Number( - screenshot?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, + captureConfig?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, ), } } diff --git a/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts b/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts index 8b5dedbffdc..6bac58b3349 100644 --- a/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts +++ b/apps/core/test/src/modules/enrichment/enrichment.service.spec.ts @@ -121,13 +121,13 @@ function makeService(stubs: ServiceStubs = {}) { takeScreenshotBytes: vi.fn(() => undefined), attachScreenshotBytes: vi.fn(), } - const screenshotPipeline = { + const capturePipeline = { process: vi.fn(async () => null), } - const screenshotStorage = { + const captureStorage = { storeOrEvict: vi.fn(async () => ({ - url: 'https://example.test/screenshot.webp', - objectKey: 'enrichment-screenshots/x.webp', + url: 'https://example.test/capture.webp', + objectKey: 'enrichment-captures/x.webp', bytes: 1024, })), delete: vi.fn(async () => undefined), @@ -143,8 +143,8 @@ function makeService(stubs: ServiceStubs = {}) { service.taskQueueService = taskQueueService service.taskQueueProcessor = taskQueueProcessor service.browserFetch = browserFetch - service.capturePipeline = screenshotPipeline - service.captureStorage = screenshotStorage + service.capturePipeline = capturePipeline + service.captureStorage = captureStorage service.logger = { warn: vi.fn(), log: vi.fn() } return { From 61d327a4078a39abb540283d7ff5ada4134d7d27 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:18:37 +0800 Subject: [PATCH 04/10] feat(enrichment): add previewImage OG URLs to all five GitHub providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each provider now populates EnrichmentResult.previewImage with the opengraph.githubassets.com hero image (1280×640). The cache token is derived from the entity's mutation timestamp (pushed_at/updated_at for repos, updated_at for issues/PRs, commit author date for commits, updatedAt for discussions) so the URL invalidates when the entity changes. The discussion GraphQL query is extended to return updatedAt. Tests assert URL shape per provider plus width/height invariants and thumbnailImage regression-guard. --- .../providers/github/github-commit.provider.ts | 10 ++++++++++ .../providers/github/github-discussion.provider.ts | 12 ++++++++++++ .../providers/github/github-issue.provider.ts | 10 ++++++++++ .../providers/github/github-pr.provider.ts | 10 ++++++++++ .../providers/github/github-repo.provider.ts | 10 ++++++++++ .../providers/github-commit.provider.spec.ts | 9 +++++++++ .../providers/github-discussion.provider.spec.ts | 7 +++++++ .../providers/github-issue.provider.spec.ts | 10 ++++++++++ .../enrichment/providers/github-pr.provider.spec.ts | 10 ++++++++++ .../providers/github-repo.provider.spec.ts | 7 +++++++ 10 files changed, 95 insertions(+) diff --git a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts index 4a50c7a75be..c8fcb9e6bbb 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts @@ -60,6 +60,10 @@ export class GitHubCommitProvider implements EnrichmentProvider { format: 'number', }) + const cacheToken = encodeURIComponent( + data.commit?.author?.date ?? new Date().toISOString(), + ) + return { title: data.commit?.message?.split('\n')[0] || id, description: @@ -68,6 +72,12 @@ export class GitHubCommitProvider implements EnrichmentProvider { thumbnailImage: data.author?.avatar_url ? { url: data.author.avatar_url, alt: data.author.login } : undefined, + previewImage: { + url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/commit/${ref}`, + width: 1280, + height: 640, + alt: `${data.commit?.message?.split('\n')[0] || ref} · ${owner}/${repo}`, + }, url: data.html_url, category: this.category, subtype: 'commit', diff --git a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts index 71bc5e7633a..20bf98cec3e 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts @@ -43,6 +43,7 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { body url createdAt + updatedAt author { login avatarUrl } comments { totalCount } } @@ -56,6 +57,7 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { body: string | null url: string createdAt: string | null + updatedAt: string | null author: { login: string; avatarUrl: string } | null comments: { totalCount: number } } | null @@ -65,12 +67,22 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { const discussion = data?.repository?.discussion if (!discussion) throw new Error(`Discussion not found: ${id}`) + const cacheToken = encodeURIComponent( + discussion.updatedAt ?? new Date().toISOString(), + ) + return { title: discussion.title, description: (discussion.body || '').slice(0, 300) || undefined, thumbnailImage: discussion.author?.avatarUrl ? { url: discussion.author.avatarUrl, alt: discussion.author.login } : undefined, + previewImage: { + url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/discussions/${number}`, + width: 1280, + height: 640, + alt: `${discussion.title} · Discussion #${number} · ${owner}/${repo}`, + }, url: discussion.url, category: this.category, subtype: 'discussion', diff --git a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts index 2e3acf5b31e..89dec4eb714 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts @@ -73,12 +73,22 @@ export class GitHubIssueProvider implements EnrichmentProvider { format: 'text', }) + const cacheToken = encodeURIComponent( + data.updated_at ?? new Date().toISOString(), + ) + return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, thumbnailImage: data.user?.avatar_url ? { url: data.user.avatar_url, alt: data.user.login } : undefined, + previewImage: { + url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/issues/${issue_number}`, + width: 1280, + height: 640, + alt: `${data.title} · Issue #${issue_number} · ${owner}/${repo}`, + }, url: data.html_url, category: this.category, subtype: 'issue', diff --git a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts index a1269a577a9..17cd828ada3 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts @@ -82,12 +82,22 @@ export class GitHubPrProvider implements EnrichmentProvider { format: 'text', }) + const cacheToken = encodeURIComponent( + data.updated_at ?? new Date().toISOString(), + ) + return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, thumbnailImage: data.user?.avatar_url ? { url: data.user.avatar_url, alt: data.user.login } : undefined, + previewImage: { + url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/pull/${pull_number}`, + width: 1280, + height: 640, + alt: `${data.title} · PR #${pull_number} · ${owner}/${repo}`, + }, url: data.html_url, category: this.category, subtype: 'pr', diff --git a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts index bd38dd1bfe8..a7e6b92ab43 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts @@ -63,12 +63,22 @@ export class GitHubRepoProvider implements EnrichmentProvider { format: 'text', }) + const cacheToken = encodeURIComponent( + data.pushed_at ?? data.updated_at ?? new Date().toISOString(), + ) + return { title: data.full_name || id, description: data.description || undefined, thumbnailImage: data.owner?.avatar_url ? { url: data.owner.avatar_url, alt: `${data.owner.login} avatar` } : undefined, + previewImage: { + url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}`, + width: 1280, + height: 640, + alt: `${data.full_name || id} on GitHub`, + }, url: data.html_url || `https://github.com/${id}`, category: this.category, subtype: 'repo', diff --git a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts index d71d9d9a051..612ea40bcbf 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts @@ -54,6 +54,15 @@ describe('GitHubCommitProvider', () => { expect(result.title).toBe('Fix critical bug') expect(result.description).toBe('This fixes issue #42.') expect(result.publishedAt).toBe('2023-06-01T00:00:00Z') + expect(result.thumbnailImage).toEqual({ + url: 'https://avatar', + alt: 'dev', + }) + expect(result.previewImage?.url).toMatch( + /^https:\/\/opengraph\.githubassets\.com\/.+\/mx-space\/core\/commit\/abc123$/, + ) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) }) }) }) diff --git a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts index 076e452c455..646342259e8 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts @@ -43,6 +43,7 @@ describe('GitHubDiscussionProvider', () => { body: 'Discussion body', url: 'https://github.com/mx-space/core/discussions/42', createdAt: '2023-06-01T00:00:00Z', + updatedAt: '2023-07-01T00:00:00Z', author: { login: 'user', avatarUrl: 'https://avatar' }, comments: { totalCount: 3 }, }, @@ -59,6 +60,11 @@ describe('GitHubDiscussionProvider', () => { url: 'https://avatar', alt: 'user', }) + expect(result.previewImage?.url).toMatch( + /^https:\/\/opengraph\.githubassets\.com\/.+\/mx-space\/core\/discussions\/42$/, + ) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) expect(result.attributes).toContainEqual({ key: 'repo', value: 'mx-space/core', @@ -93,6 +99,7 @@ describe('GitHubDiscussionProvider', () => { body: null, url: 'https://github.com/mx-space/core/discussions/1', createdAt: null, + updatedAt: null, author: null, comments: { totalCount: 0 }, }, diff --git a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts index 7985bcfe19f..aaddb58fbdd 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts @@ -56,6 +56,7 @@ describe('GitHubIssueProvider', () => { state: 'open', comments: 5, created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', user: { avatar_url: 'https://avatar', login: 'testuser' }, } const p = new GitHubIssueProvider(createClient(mockData)) @@ -64,6 +65,15 @@ describe('GitHubIssueProvider', () => { expect(result.title).toBe('Bug fix') expect(result.subtype).toBe('issue') + expect(result.thumbnailImage).toEqual({ + url: 'https://avatar', + alt: 'testuser', + }) + expect(result.previewImage?.url).toMatch( + /^https:\/\/opengraph\.githubassets\.com\/.+\/mx-space\/core\/issues\/42$/, + ) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) expect(result.attributes).toContainEqual({ key: 'repo', value: 'mx-space/core', diff --git a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts index a6af1be67d4..b91e3cb8ca1 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts @@ -48,6 +48,7 @@ describe('GitHubPrProvider', () => { additions: 100, deletions: 20, created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', user: { avatar_url: 'https://avatar', login: 'dev' }, } const p = new GitHubPrProvider(createClient(mockData)) @@ -56,6 +57,15 @@ describe('GitHubPrProvider', () => { expect(result.title).toBe('Fix bug') expect(result.subtype).toBe('pr') + expect(result.thumbnailImage).toEqual({ + url: 'https://avatar', + alt: 'dev', + }) + expect(result.previewImage?.url).toMatch( + /^https:\/\/opengraph\.githubassets\.com\/.+\/mx-space\/core\/pull\/42$/, + ) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) expect(result.attributes).toContainEqual({ key: 'repo', value: 'mx-space/core', diff --git a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts index 31359718f91..64ba64b552d 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts @@ -11,6 +11,8 @@ const makeRepoResponse = (overrides: Record = {}) => ({ forks_count: 20, language: 'TypeScript', created_at: '2023-01-01T00:00:00Z', + pushed_at: '2023-06-01T00:00:00Z', + updated_at: '2023-05-01T00:00:00Z', owner: { avatar_url: 'https://avatar.url', login: 'mx-space' }, license: { spdx_id: 'MIT' }, ...overrides, @@ -87,6 +89,11 @@ describe('GitHubRepoProvider', () => { url: 'https://avatar.url', alt: 'mx-space avatar', }) + expect(result.previewImage?.url).toMatch( + /^https:\/\/opengraph\.githubassets\.com\/.+\/mx-space\/core$/, + ) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) expect(result.publishedAt).toBe('2023-01-01T00:00:00Z') expect(result.color).toBe('TypeScript') From 2c201dcbbbb97e280060f2f6ab724ffda7d80a05 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:32:46 +0800 Subject: [PATCH 05/10] refactor(enrichment): extract GitHub OG URL helper and image dimension constants --- .../github/github-commit.provider.ts | 23 ++++++++++++------- .../github/github-discussion.provider.ts | 23 ++++++++++++------- .../providers/github/github-issue.provider.ts | 23 ++++++++++++------- .../providers/github/github-pr.provider.ts | 17 +++++++------- .../providers/github/github-repo.provider.ts | 17 +++++++------- .../providers/github/github.client.ts | 13 +++++++++++ 6 files changed, 76 insertions(+), 40 deletions(-) diff --git a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts index c8fcb9e6bbb..6b7986de675 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts @@ -3,7 +3,12 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' -import { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubCommitProvider implements EnrichmentProvider { @@ -60,10 +65,6 @@ export class GitHubCommitProvider implements EnrichmentProvider { format: 'number', }) - const cacheToken = encodeURIComponent( - data.commit?.author?.date ?? new Date().toISOString(), - ) - return { title: data.commit?.message?.split('\n')[0] || id, description: @@ -73,9 +74,15 @@ export class GitHubCommitProvider implements EnrichmentProvider { ? { url: data.author.avatar_url, alt: data.author.login } : undefined, previewImage: { - url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/commit/${ref}`, - width: 1280, - height: 640, + url: buildOgImageUrl( + data.commit?.author?.date, + owner, + repo, + 'commit', + ref, + ), + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, alt: `${data.commit?.message?.split('\n')[0] || ref} · ${owner}/${repo}`, }, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts index 20bf98cec3e..449d067f21e 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts @@ -3,7 +3,12 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' -import { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubDiscussionProvider implements EnrichmentProvider { @@ -67,10 +72,6 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { const discussion = data?.repository?.discussion if (!discussion) throw new Error(`Discussion not found: ${id}`) - const cacheToken = encodeURIComponent( - discussion.updatedAt ?? new Date().toISOString(), - ) - return { title: discussion.title, description: (discussion.body || '').slice(0, 300) || undefined, @@ -78,9 +79,15 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { ? { url: discussion.author.avatarUrl, alt: discussion.author.login } : undefined, previewImage: { - url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/discussions/${number}`, - width: 1280, - height: 640, + url: buildOgImageUrl( + discussion.updatedAt, + owner, + repo, + 'discussions', + number, + ), + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, alt: `${discussion.title} · Discussion #${number} · ${owner}/${repo}`, }, url: discussion.url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts index 89dec4eb714..bec1a285807 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts @@ -3,7 +3,12 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' -import { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubIssueProvider implements EnrichmentProvider { @@ -73,10 +78,6 @@ export class GitHubIssueProvider implements EnrichmentProvider { format: 'text', }) - const cacheToken = encodeURIComponent( - data.updated_at ?? new Date().toISOString(), - ) - return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, @@ -84,9 +85,15 @@ export class GitHubIssueProvider implements EnrichmentProvider { ? { url: data.user.avatar_url, alt: data.user.login } : undefined, previewImage: { - url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/issues/${issue_number}`, - width: 1280, - height: 640, + url: buildOgImageUrl( + data.updated_at, + owner, + repo, + 'issues', + issue_number, + ), + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, alt: `${data.title} · Issue #${issue_number} · ${owner}/${repo}`, }, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts index 17cd828ada3..869ceb3fd88 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts @@ -3,7 +3,12 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' -import { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubPrProvider implements EnrichmentProvider { @@ -82,10 +87,6 @@ export class GitHubPrProvider implements EnrichmentProvider { format: 'text', }) - const cacheToken = encodeURIComponent( - data.updated_at ?? new Date().toISOString(), - ) - return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, @@ -93,9 +94,9 @@ export class GitHubPrProvider implements EnrichmentProvider { ? { url: data.user.avatar_url, alt: data.user.login } : undefined, previewImage: { - url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}/pull/${pull_number}`, - width: 1280, - height: 640, + url: buildOgImageUrl(data.updated_at, owner, repo, 'pull', pull_number), + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, alt: `${data.title} · PR #${pull_number} · ${owner}/${repo}`, }, url: data.html_url, diff --git a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts index a7e6b92ab43..05cf8f679e6 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts @@ -3,7 +3,12 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' -import { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubRepoProvider implements EnrichmentProvider { @@ -63,10 +68,6 @@ export class GitHubRepoProvider implements EnrichmentProvider { format: 'text', }) - const cacheToken = encodeURIComponent( - data.pushed_at ?? data.updated_at ?? new Date().toISOString(), - ) - return { title: data.full_name || id, description: data.description || undefined, @@ -74,9 +75,9 @@ export class GitHubRepoProvider implements EnrichmentProvider { ? { url: data.owner.avatar_url, alt: `${data.owner.login} avatar` } : undefined, previewImage: { - url: `https://opengraph.githubassets.com/${cacheToken}/${owner}/${repo}`, - width: 1280, - height: 640, + url: buildOgImageUrl(data.pushed_at ?? data.updated_at, owner, repo), + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, alt: `${data.full_name || id} on GitHub`, }, url: data.html_url || `https://github.com/${id}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github.client.ts b/apps/core/src/modules/enrichment/providers/github/github.client.ts index 8c5c822bf10..3528a276543 100644 --- a/apps/core/src/modules/enrichment/providers/github/github.client.ts +++ b/apps/core/src/modules/enrichment/providers/github/github.client.ts @@ -3,6 +3,19 @@ import { Octokit } from 'octokit' import { ConfigsService } from '~/modules/configs/configs.service' +export const OG_IMAGE_WIDTH = 1280 +export const OG_IMAGE_HEIGHT = 640 + +export function buildOgImageUrl( + cacheTimestamp: string | null | undefined, + ...pathSegments: string[] +): string { + const cacheToken = encodeURIComponent( + cacheTimestamp ?? new Date().toISOString(), + ) + return `https://opengraph.githubassets.com/${cacheToken}/${pathSegments.join('/')}` +} + @Injectable() export class GitHubClient { private readonly logger = new Logger(GitHubClient.name) From 765238e7a654e34d417dc3d135a2d4dc207ed00c Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:53:12 +0800 Subject: [PATCH 06/10] refactor(enrichment): unify image shape under EnrichmentImage with optional palette --- .../enrichment-capture.repository.ts | 8 +++---- .../enrichment/enrichment.repository.ts | 4 ++-- .../modules/enrichment/enrichment.types.ts | 21 +++++++------------ packages/api-client/models/recently.ts | 21 +++++++------------ packages/api-client/package.json | 2 +- packages/db-schema/src/schema/enrichment.ts | 4 ++-- 6 files changed, 23 insertions(+), 37 deletions(-) diff --git a/apps/core/src/modules/enrichment/enrichment-capture.repository.ts b/apps/core/src/modules/enrichment/enrichment-capture.repository.ts index bbe68704701..fab6ea3a51c 100644 --- a/apps/core/src/modules/enrichment/enrichment-capture.repository.ts +++ b/apps/core/src/modules/enrichment/enrichment-capture.repository.ts @@ -4,8 +4,8 @@ import { asc, desc, eq, sql } from 'drizzle-orm' import { PG_DB_TOKEN } from '~/constants/system.constant' import { enrichmentCache, - type EnrichmentCapturePalette, enrichmentCaptures, + type EnrichmentImagePalette, } from '~/database/schema' import type { PaginationResult } from '~/processors/database/base.repository' import { BaseRepository } from '~/processors/database/base.repository' @@ -18,7 +18,7 @@ export interface EnrichmentCaptureRow { width: number height: number blurhash: string | null - palette: EnrichmentCapturePalette | null + palette: EnrichmentImagePalette | null createdAt: Date lastAccessedAt: Date } @@ -34,7 +34,7 @@ export interface EnrichmentCaptureJoinedRow { width: number height: number blurhash: string | null - palette: EnrichmentCapturePalette | null + palette: EnrichmentImagePalette | null createdAt: Date lastAccessedAt: Date } @@ -49,7 +49,7 @@ export interface EnrichmentCaptureInsert { width: number height: number blurhash?: string | null - palette?: EnrichmentCapturePalette | null + palette?: EnrichmentImagePalette | null } @Injectable() diff --git a/apps/core/src/modules/enrichment/enrichment.repository.ts b/apps/core/src/modules/enrichment/enrichment.repository.ts index 21f654115f9..eedabad7a20 100644 --- a/apps/core/src/modules/enrichment/enrichment.repository.ts +++ b/apps/core/src/modules/enrichment/enrichment.repository.ts @@ -8,7 +8,7 @@ import type { AppDatabase } from '~/processors/database/postgres.provider' import { SnowflakeService } from '~/shared/id/snowflake.service' import type { - EnrichmentCapture, + EnrichmentImage, EnrichmentResult, EnrichmentRow, } from './enrichment.types' @@ -166,7 +166,7 @@ export class EnrichmentRepository extends BaseRepository { */ async updateCapture( id: string, - captureImage: EnrichmentCapture, + captureImage: EnrichmentImage, ): Promise { const patch = JSON.stringify({ captureImage }) const updated = await this.db diff --git a/apps/core/src/modules/enrichment/enrichment.types.ts b/apps/core/src/modules/enrichment/enrichment.types.ts index 8579e75c0e8..e96c883062b 100644 --- a/apps/core/src/modules/enrichment/enrichment.types.ts +++ b/apps/core/src/modules/enrichment/enrichment.types.ts @@ -1,9 +1,15 @@ +export interface EnrichmentImagePalette { + dominant: string + swatches?: string[] +} + export interface EnrichmentImage { url: string width?: number height?: number alt?: string blurhash?: string + palette?: EnrichmentImagePalette } export interface EnrichmentAttribute { @@ -13,19 +19,6 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentCapturePalette { - dominant: string - swatches?: string[] -} - -export interface EnrichmentCapture { - url: string - width: number - height: number - blurhash?: string - palette?: EnrichmentCapturePalette -} - export interface EnrichmentResult { id?: string @@ -50,7 +43,7 @@ export interface EnrichmentResult { links?: Array<{ rel: string; url: string; label?: string }> - captureImage?: EnrichmentCapture + captureImage?: EnrichmentImage raw?: TRaw } diff --git a/packages/api-client/models/recently.ts b/packages/api-client/models/recently.ts index a8ff025cdcf..ff33923173f 100644 --- a/packages/api-client/models/recently.ts +++ b/packages/api-client/models/recently.ts @@ -30,12 +30,18 @@ export enum RecentlyTypeEnum { Link = 'link', } +export interface EnrichmentImagePalette { + dominant: string + swatches?: string[] +} + export interface EnrichmentImage { url: string width?: number height?: number alt?: string blurhash?: string + palette?: EnrichmentImagePalette } export interface EnrichmentAttribute { @@ -45,19 +51,6 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentCapturePalette { - dominant: string - swatches?: string[] -} - -export interface EnrichmentCapture { - url: string - width: number - height: number - blurhash?: string - palette?: EnrichmentCapturePalette -} - export interface EnrichmentResult { id?: string @@ -73,7 +66,7 @@ export interface EnrichmentResult { attributes?: EnrichmentAttribute[] color?: string links?: Array<{ rel: string; url: string; label?: string }> - captureImage?: EnrichmentCapture + captureImage?: EnrichmentImage } /** diff --git a/packages/api-client/package.json b/packages/api-client/package.json index bddbdcb940f..7a26eece82b 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@mx-space/api-client", - "version": "4.3.0", + "version": "4.4.0", "description": "A api client for mx-space server@next", "type": "module", "engines": { diff --git a/packages/db-schema/src/schema/enrichment.ts b/packages/db-schema/src/schema/enrichment.ts index c6db7d6cd8f..6473e403b39 100644 --- a/packages/db-schema/src/schema/enrichment.ts +++ b/packages/db-schema/src/schema/enrichment.ts @@ -42,7 +42,7 @@ export const enrichmentCache = pgTable( ], ) -export interface EnrichmentCapturePalette { +export interface EnrichmentImagePalette { dominant: string swatches?: string[] } @@ -59,7 +59,7 @@ export const enrichmentCaptures = pgTable( width: integer('width').notNull(), height: integer('height').notNull(), blurhash: text('blurhash'), - palette: jsonb('palette').$type(), + palette: jsonb('palette').$type(), createdAt: createdAt(), lastAccessedAt: timestamp('last_accessed_at', { withTimezone: true, From 3007c6f42380457c05a19fb1f046fedf02c73769 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 20:55:26 +0800 Subject: [PATCH 07/10] feat(enrichment): add ImageMetaService for blurhash/palette extraction --- .../modules/enrichment/enrichment.module.ts | 2 + .../providers/image-meta.service.ts | 108 ++++++++++++++++++ .../providers/image-meta.service.spec.ts | 86 ++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 apps/core/src/modules/enrichment/providers/image-meta.service.ts create mode 100644 apps/core/test/src/modules/enrichment/providers/image-meta.service.spec.ts diff --git a/apps/core/src/modules/enrichment/enrichment.module.ts b/apps/core/src/modules/enrichment/enrichment.module.ts index e4aee16413d..93cbfcf8dc0 100644 --- a/apps/core/src/modules/enrichment/enrichment.module.ts +++ b/apps/core/src/modules/enrichment/enrichment.module.ts @@ -16,6 +16,7 @@ import { GitHubDiscussionProvider } from './providers/github/github-discussion.p import { GitHubIssueProvider } from './providers/github/github-issue.provider' import { GitHubPrProvider } from './providers/github/github-pr.provider' import { GitHubRepoProvider } from './providers/github/github-repo.provider' +import { ImageMetaService } from './providers/image-meta.service' import { LeetcodeProvider } from './providers/leetcode/leetcode.provider' import { NeoDBBookProvider } from './providers/neodb/neodb-book.provider' import { NeteaseMusicProvider } from './providers/netease/netease-music.provider' @@ -69,6 +70,7 @@ const allProviders = [ BrowserFetchService, CapturePipelineService, CaptureStorageService, + ImageMetaService, ...allProviders, ], exports: [EnrichmentService, UrlExtractorService], diff --git a/apps/core/src/modules/enrichment/providers/image-meta.service.ts b/apps/core/src/modules/enrichment/providers/image-meta.service.ts new file mode 100644 index 00000000000..2c26405df99 --- /dev/null +++ b/apps/core/src/modules/enrichment/providers/image-meta.service.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common' +import { encode } from 'blurhash' +import sharp from 'sharp' + +const FETCH_TIMEOUT_MS = 5000 +const MAX_BYTES = 5 * 1024 * 1024 + +const BLURHASH_SIZE = 32 +const BLURHASH_COMP_X = 4 +const BLURHASH_COMP_Y = 4 + +export interface ImageMeta { + width?: number + height?: number + blurhash?: string + palette?: { dominant: string; swatches?: string[] } +} + +@Injectable() +export class ImageMetaService { + private readonly logger = new Logger(ImageMetaService.name) + + async fetchAndExtract(url: string): Promise { + try { + const buffer = await this.fetchBuffer(url) + if (!buffer) return null + return await this.extract(buffer) + } catch (error) { + this.logger.debug( + `image-meta: extract failed for ${url}: ${(error as Error).message}`, + ) + return null + } + } + + private async fetchBuffer(url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok || !response.body) { + this.logger.debug( + `image-meta: fetch ${url} returned status ${response.status}`, + ) + return null + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value) { + total += value.byteLength + if (total > MAX_BYTES) { + await reader.cancel() + this.logger.debug( + `image-meta: fetch ${url} exceeded ${MAX_BYTES} bytes; aborting`, + ) + return null + } + chunks.push(value) + } + } + return Buffer.concat(chunks.map((c) => Buffer.from(c))) + } finally { + clearTimeout(timer) + } + } + + private async extract(buffer: Buffer): Promise { + const sharped = sharp(buffer) + const metadata = await sharped.metadata() + const { dominant } = await sharped.stats() + const dominantHex = rgbToHex(dominant.r, dominant.g, dominant.b) + const blurhash = await encodeBlurhash(sharped) + return { + width: metadata.width, + height: metadata.height, + blurhash, + palette: { dominant: dominantHex }, + } + } +} + +function rgbToHex(r: number, g: number, b: number): string { + const h = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, '0') + return `#${h(r)}${h(g)}${h(b)}` +} + +async function encodeBlurhash(source: sharp.Sharp): Promise { + const { data, info } = await source + .clone() + .raw() + .ensureAlpha() + .resize(BLURHASH_SIZE, BLURHASH_SIZE, { fit: 'inside' }) + .toBuffer({ resolveWithObject: true }) + return encode( + new Uint8ClampedArray(data), + info.width, + info.height, + BLURHASH_COMP_X, + BLURHASH_COMP_Y, + ) +} diff --git a/apps/core/test/src/modules/enrichment/providers/image-meta.service.spec.ts b/apps/core/test/src/modules/enrichment/providers/image-meta.service.spec.ts new file mode 100644 index 00000000000..2a512ffe658 --- /dev/null +++ b/apps/core/test/src/modules/enrichment/providers/image-meta.service.spec.ts @@ -0,0 +1,86 @@ +import sharp from 'sharp' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +async function makePngBuffer(width = 32, height = 32): Promise { + return sharp({ + create: { + width, + height, + channels: 3, + background: { r: 200, g: 100, b: 50 }, + }, + }) + .png() + .toBuffer() +} + +function makeFetchResponse(body: Uint8Array, status = 200): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(body) + controller.close() + }, + }) + return new Response(stream, { status }) +} + +describe('ImageMetaService', () => { + let service: ImageMetaService + const originalFetch = globalThis.fetch + + beforeEach(() => { + service = new ImageMetaService() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('returns width/height/blurhash/dominant palette for a valid image', async () => { + const buffer = await makePngBuffer() + globalThis.fetch = vi + .fn() + .mockResolvedValue(makeFetchResponse(new Uint8Array(buffer))) + + const meta = await service.fetchAndExtract('https://example.test/img.png') + + expect(meta).not.toBeNull() + expect(meta!.width).toBe(32) + expect(meta!.height).toBe(32) + expect(typeof meta!.blurhash).toBe('string') + expect(meta!.blurhash!.length).toBeGreaterThan(0) + expect(meta!.palette?.dominant).toMatch(/^#[\da-f]{6}$/) + }) + + it('returns null on non-OK fetch status', async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response('not found', { status: 404 })) + + const meta = await service.fetchAndExtract( + 'https://example.test/missing.png', + ) + + expect(meta).toBeNull() + }) + + it('returns null when fetch throws (network error / timeout)', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down')) + + const meta = await service.fetchAndExtract('https://example.test/x.png') + + expect(meta).toBeNull() + }) + + it('returns null when buffer is not decodable by sharp', async () => { + const garbage = new Uint8Array([0, 1, 2, 3, 4, 5]) + globalThis.fetch = vi.fn().mockResolvedValue(makeFetchResponse(garbage)) + + const meta = await service.fetchAndExtract('https://example.test/bad.png') + + expect(meta).toBeNull() + }) +}) From b32509a8d3632333f35cb7e8a35370790eabf111 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 21:12:39 +0800 Subject: [PATCH 08/10] feat(enrichment): fetch blurhash/palette for GitHub thumbnail and preview images --- .../github/github-commit.provider.ts | 36 +++++++++---- .../github/github-discussion.provider.ts | 36 +++++++++---- .../providers/github/github-issue.provider.ts | 36 +++++++++---- .../providers/github/github-pr.provider.ts | 30 +++++++++-- .../providers/github/github-repo.provider.ts | 28 ++++++++-- .../providers/github-commit.provider.spec.ts | 13 ++++- .../github-discussion.provider.spec.ts | 27 ++++++++-- .../providers/github-issue.provider.spec.ts | 10 +++- .../providers/github-pr.provider.spec.ts | 10 +++- .../providers/github-repo.provider.spec.ts | 53 +++++++++++++++++-- 10 files changed, 228 insertions(+), 51 deletions(-) diff --git a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts index 6b7986de675..573adfe1ee5 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-commit.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' +import { ImageMetaService } from '../image-meta.service' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' import { @@ -20,7 +21,10 @@ export class GitHubCommitProvider implements EnrichmentProvider { readonly featureGateConfigKey = 'github' readonly requiredConfigKeys = ['token'] - constructor(private readonly client: GitHubClient) {} + constructor( + private readonly client: GitHubClient, + private readonly imageMeta: ImageMetaService, + ) {} matchUrl(url: URL): UrlMatchResult | null { if (url.hostname !== 'github.com') return null @@ -65,22 +69,34 @@ export class GitHubCommitProvider implements EnrichmentProvider { format: 'number', }) + const avatarUrl = data.author?.avatar_url ?? null + const ogUrl = buildOgImageUrl( + data.commit?.author?.date, + owner, + repo, + 'commit', + ref, + ) + const [avatarMeta, ogMeta] = await Promise.all([ + avatarUrl ? this.imageMeta.fetchAndExtract(avatarUrl) : null, + this.imageMeta.fetchAndExtract(ogUrl), + ]) + return { title: data.commit?.message?.split('\n')[0] || id, description: data.commit?.message?.split('\n').slice(1).join('\n').trim() || undefined, - thumbnailImage: data.author?.avatar_url - ? { url: data.author.avatar_url, alt: data.author.login } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: data.author!.login, + ...avatarMeta, + } : undefined, previewImage: { - url: buildOgImageUrl( - data.commit?.author?.date, - owner, - repo, - 'commit', - ref, - ), + url: ogUrl, + ...ogMeta, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, alt: `${data.commit?.message?.split('\n')[0] || ref} · ${owner}/${repo}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts index 449d067f21e..f05c073c4d8 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-discussion.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' +import { ImageMetaService } from '../image-meta.service' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' import { @@ -20,7 +21,10 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { readonly featureGateConfigKey = 'github' readonly requiredConfigKeys = ['token'] - constructor(private readonly client: GitHubClient) {} + constructor( + private readonly client: GitHubClient, + private readonly imageMeta: ImageMetaService, + ) {} matchUrl(url: URL): UrlMatchResult | null { if (url.hostname !== 'github.com') return null @@ -72,20 +76,32 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { const discussion = data?.repository?.discussion if (!discussion) throw new Error(`Discussion not found: ${id}`) + const avatarUrl = discussion.author?.avatarUrl ?? null + const ogUrl = buildOgImageUrl( + discussion.updatedAt, + owner, + repo, + 'discussions', + number, + ) + const [avatarMeta, ogMeta] = await Promise.all([ + avatarUrl ? this.imageMeta.fetchAndExtract(avatarUrl) : null, + this.imageMeta.fetchAndExtract(ogUrl), + ]) + return { title: discussion.title, description: (discussion.body || '').slice(0, 300) || undefined, - thumbnailImage: discussion.author?.avatarUrl - ? { url: discussion.author.avatarUrl, alt: discussion.author.login } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: discussion.author!.login, + ...avatarMeta, + } : undefined, previewImage: { - url: buildOgImageUrl( - discussion.updatedAt, - owner, - repo, - 'discussions', - number, - ), + url: ogUrl, + ...ogMeta, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, alt: `${discussion.title} · Discussion #${number} · ${owner}/${repo}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts index bec1a285807..ad8df90ad1b 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-issue.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' +import { ImageMetaService } from '../image-meta.service' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' import { @@ -20,7 +21,10 @@ export class GitHubIssueProvider implements EnrichmentProvider { readonly featureGateConfigKey = 'github' readonly requiredConfigKeys = ['token'] - constructor(private readonly client: GitHubClient) {} + constructor( + private readonly client: GitHubClient, + private readonly imageMeta: ImageMetaService, + ) {} matchUrl(url: URL): UrlMatchResult | null { if (url.hostname !== 'github.com') return null @@ -78,20 +82,32 @@ export class GitHubIssueProvider implements EnrichmentProvider { format: 'text', }) + const avatarUrl = data.user?.avatar_url ?? null + const ogUrl = buildOgImageUrl( + data.updated_at, + owner, + repo, + 'issues', + issue_number, + ) + const [avatarMeta, ogMeta] = await Promise.all([ + avatarUrl ? this.imageMeta.fetchAndExtract(avatarUrl) : null, + this.imageMeta.fetchAndExtract(ogUrl), + ]) + return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, - thumbnailImage: data.user?.avatar_url - ? { url: data.user.avatar_url, alt: data.user.login } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: data.user!.login, + ...avatarMeta, + } : undefined, previewImage: { - url: buildOgImageUrl( - data.updated_at, - owner, - repo, - 'issues', - issue_number, - ), + url: ogUrl, + ...ogMeta, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, alt: `${data.title} · Issue #${issue_number} · ${owner}/${repo}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts index 869ceb3fd88..2cc35febc6a 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-pr.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' +import { ImageMetaService } from '../image-meta.service' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' import { @@ -20,7 +21,10 @@ export class GitHubPrProvider implements EnrichmentProvider { readonly featureGateConfigKey = 'github' readonly requiredConfigKeys = ['token'] - constructor(private readonly client: GitHubClient) {} + constructor( + private readonly client: GitHubClient, + private readonly imageMeta: ImageMetaService, + ) {} matchUrl(url: URL): UrlMatchResult | null { if (url.hostname !== 'github.com') return null @@ -87,14 +91,32 @@ export class GitHubPrProvider implements EnrichmentProvider { format: 'text', }) + const avatarUrl = data.user?.avatar_url ?? null + const ogUrl = buildOgImageUrl( + data.updated_at, + owner, + repo, + 'pull', + pull_number, + ) + const [avatarMeta, ogMeta] = await Promise.all([ + avatarUrl ? this.imageMeta.fetchAndExtract(avatarUrl) : null, + this.imageMeta.fetchAndExtract(ogUrl), + ]) + return { title: data.title, description: (data.body || '').slice(0, 300) || undefined, - thumbnailImage: data.user?.avatar_url - ? { url: data.user.avatar_url, alt: data.user.login } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: data.user!.login, + ...avatarMeta, + } : undefined, previewImage: { - url: buildOgImageUrl(data.updated_at, owner, repo, 'pull', pull_number), + url: ogUrl, + ...ogMeta, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, alt: `${data.title} · PR #${pull_number} · ${owner}/${repo}`, diff --git a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts index 05cf8f679e6..9f6bff3654f 100644 --- a/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts +++ b/apps/core/src/modules/enrichment/providers/github/github-repo.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common' import type { EnrichmentResult, UrlMatchResult } from '../../enrichment.types' +import { ImageMetaService } from '../image-meta.service' import { ENRICHMENT_CATEGORIES } from '../provider.constants' import type { EnrichmentProvider } from '../provider.interface' import { @@ -20,7 +21,10 @@ export class GitHubRepoProvider implements EnrichmentProvider { readonly featureGateConfigKey = 'github' readonly requiredConfigKeys = ['token'] - constructor(private readonly client: GitHubClient) {} + constructor( + private readonly client: GitHubClient, + private readonly imageMeta: ImageMetaService, + ) {} matchUrl(url: URL): UrlMatchResult | null { if (url.hostname !== 'github.com') return null @@ -68,14 +72,30 @@ export class GitHubRepoProvider implements EnrichmentProvider { format: 'text', }) + const avatarUrl = data.owner?.avatar_url ?? null + const ogUrl = buildOgImageUrl( + data.pushed_at ?? data.updated_at, + owner, + repo, + ) + const [avatarMeta, ogMeta] = await Promise.all([ + avatarUrl ? this.imageMeta.fetchAndExtract(avatarUrl) : null, + this.imageMeta.fetchAndExtract(ogUrl), + ]) + return { title: data.full_name || id, description: data.description || undefined, - thumbnailImage: data.owner?.avatar_url - ? { url: data.owner.avatar_url, alt: `${data.owner.login} avatar` } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: `${data.owner!.login} avatar`, + ...avatarMeta, + } : undefined, previewImage: { - url: buildOgImageUrl(data.pushed_at ?? data.updated_at, owner, repo), + url: ogUrl, + ...ogMeta, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, alt: `${data.full_name || id} on GitHub`, diff --git a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts index 612ea40bcbf..838adadb3cd 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubClient } from '~/modules/enrichment/providers/github/github.client' import { GitHubCommitProvider } from '~/modules/enrichment/providers/github/github-commit.provider' +import type { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +const stubImageMeta = (result: any = null): ImageMetaService => + ({ + fetchAndExtract: vi.fn(async () => result), + }) as unknown as ImageMetaService const createClient = (mockData: Record) => ({ @@ -14,7 +20,7 @@ const createClient = (mockData: Record) => describe('GitHubCommitProvider', () => { describe('matchUrl', () => { - const provider = new GitHubCommitProvider(createClient({})) + const provider = new GitHubCommitProvider(createClient({}), stubImageMeta()) it('matches github.com/owner/repo/commit/sha', () => { const result = provider.matchUrl( @@ -47,7 +53,10 @@ describe('GitHubCommitProvider', () => { author: { avatar_url: 'https://avatar', login: 'dev' }, stats: { additions: 10, deletions: 5 }, } - const p = new GitHubCommitProvider(createClient(mockData)) + const p = new GitHubCommitProvider( + createClient(mockData), + stubImageMeta(), + ) const result = await p.fetch('mx-space/core/commits/abc123') diff --git a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts index 646342259e8..29ece8237f1 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubClient } from '~/modules/enrichment/providers/github/github.client' import { GitHubDiscussionProvider } from '~/modules/enrichment/providers/github/github-discussion.provider' +import type { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +const stubImageMeta = (result: any = null): ImageMetaService => + ({ + fetchAndExtract: vi.fn(async () => result), + }) as unknown as ImageMetaService const createClient = (mockData: any) => ({ @@ -12,7 +18,10 @@ const createClient = (mockData: any) => describe('GitHubDiscussionProvider', () => { describe('matchUrl', () => { - const provider = new GitHubDiscussionProvider(createClient({})) + const provider = new GitHubDiscussionProvider( + createClient({}), + stubImageMeta(), + ) it('matches github.com/owner/repo/discussions/123', () => { const result = provider.matchUrl( @@ -49,7 +58,10 @@ describe('GitHubDiscussionProvider', () => { }, }, } - const p = new GitHubDiscussionProvider(createClient(mockData)) + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(), + ) const result = await p.fetch('mx-space/core/discussions/42') @@ -105,7 +117,10 @@ describe('GitHubDiscussionProvider', () => { }, }, } - const p = new GitHubDiscussionProvider(createClient(mockData)) + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(), + ) const result = await p.fetch('mx-space/core/discussions/1') @@ -121,6 +136,7 @@ describe('GitHubDiscussionProvider', () => { it('throws when discussion not found', async () => { const p = new GitHubDiscussionProvider( createClient({ repository: { discussion: null } }), + stubImageMeta(), ) await expect(p.fetch('mx-space/core/discussions/999')).rejects.toThrow( @@ -129,7 +145,10 @@ describe('GitHubDiscussionProvider', () => { }) it('throws when repository not found', async () => { - const p = new GitHubDiscussionProvider(createClient({ repository: null })) + const p = new GitHubDiscussionProvider( + createClient({ repository: null }), + stubImageMeta(), + ) await expect(p.fetch('ghost/repo/discussions/1')).rejects.toThrow( 'Discussion not found', diff --git a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts index aaddb58fbdd..d6e924366f4 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubClient } from '~/modules/enrichment/providers/github/github.client' import { GitHubIssueProvider } from '~/modules/enrichment/providers/github/github-issue.provider' +import type { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +const stubImageMeta = (result: any = null): ImageMetaService => + ({ + fetchAndExtract: vi.fn(async () => result), + }) as unknown as ImageMetaService const createClient = (mockData: Record) => ({ @@ -14,7 +20,7 @@ const createClient = (mockData: Record) => describe('GitHubIssueProvider', () => { describe('matchUrl', () => { - const provider = new GitHubIssueProvider(createClient({})) + const provider = new GitHubIssueProvider(createClient({}), stubImageMeta()) it('matches github.com/owner/repo/issues/123', () => { const result = provider.matchUrl( @@ -59,7 +65,7 @@ describe('GitHubIssueProvider', () => { updated_at: '2023-07-01T00:00:00Z', user: { avatar_url: 'https://avatar', login: 'testuser' }, } - const p = new GitHubIssueProvider(createClient(mockData)) + const p = new GitHubIssueProvider(createClient(mockData), stubImageMeta()) const result = await p.fetch('mx-space/core/issues/42') diff --git a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts index b91e3cb8ca1..88e507b63c5 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubClient } from '~/modules/enrichment/providers/github/github.client' import { GitHubPrProvider } from '~/modules/enrichment/providers/github/github-pr.provider' +import type { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +const stubImageMeta = (result: any = null): ImageMetaService => + ({ + fetchAndExtract: vi.fn(async () => result), + }) as unknown as ImageMetaService const createClient = (mockData: Record) => ({ @@ -14,7 +20,7 @@ const createClient = (mockData: Record) => describe('GitHubPrProvider', () => { describe('matchUrl', () => { - const provider = new GitHubPrProvider(createClient({})) + const provider = new GitHubPrProvider(createClient({}), stubImageMeta()) it('matches github.com/owner/repo/pull/123', () => { const result = provider.matchUrl( @@ -51,7 +57,7 @@ describe('GitHubPrProvider', () => { updated_at: '2023-07-01T00:00:00Z', user: { avatar_url: 'https://avatar', login: 'dev' }, } - const p = new GitHubPrProvider(createClient(mockData)) + const p = new GitHubPrProvider(createClient(mockData), stubImageMeta()) const result = await p.fetch('mx-space/core/pulls/42') diff --git a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts index 64ba64b552d..4866dc137b4 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-repo.provider.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubClient } from '~/modules/enrichment/providers/github/github.client' import { GitHubRepoProvider } from '~/modules/enrichment/providers/github/github-repo.provider' +import type { ImageMetaService } from '~/modules/enrichment/providers/image-meta.service' + +const stubImageMeta = (result: any = null): ImageMetaService => + ({ + fetchAndExtract: vi.fn(async () => result), + }) as unknown as ImageMetaService const makeRepoResponse = (overrides: Record = {}) => ({ full_name: 'mx-space/core', @@ -29,7 +35,7 @@ const createClient = (mockData: Record) => describe('GitHubRepoProvider', () => { describe('matchUrl', () => { - const provider = new GitHubRepoProvider(createClient({})) + const provider = new GitHubRepoProvider(createClient({}), stubImageMeta()) it('matches github.com/owner/repo', () => { const result = provider.matchUrl( @@ -62,7 +68,7 @@ describe('GitHubRepoProvider', () => { }) describe('isValidId', () => { - const provider = new GitHubRepoProvider(createClient({})) + const provider = new GitHubRepoProvider(createClient({}), stubImageMeta()) it('accepts owner/repo format', () => { expect(provider.isValidId('mx-space/core')).toBe(true) @@ -77,7 +83,10 @@ describe('GitHubRepoProvider', () => { describe('fetch', () => { it('normalizes GitHub API response with all fields', async () => { - const p = new GitHubRepoProvider(createClient(makeRepoResponse())) + const p = new GitHubRepoProvider( + createClient(makeRepoResponse()), + stubImageMeta(), + ) const result = await p.fetch('mx-space/core') @@ -136,6 +145,7 @@ describe('GitHubRepoProvider', () => { forks_count: null, }), ), + stubImageMeta(), ) const result = await p.fetch('mx-space/core') @@ -146,5 +156,42 @@ describe('GitHubRepoProvider', () => { expect(result.color).toBeUndefined() expect(result.attributes).toEqual([]) }) + + it('merges fetched blurhash/palette into thumbnail and preview images', async () => { + const meta = { + width: 64, + height: 64, + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + palette: { dominant: '#abcdef' }, + } + const p = new GitHubRepoProvider( + createClient(makeRepoResponse()), + stubImageMeta(meta), + ) + + const result = await p.fetch('mx-space/core') + + expect(result.thumbnailImage?.blurhash).toBe(meta.blurhash) + expect(result.thumbnailImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.blurhash).toBe(meta.blurhash) + expect(result.previewImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) + }) + + it('omits blurhash when ImageMetaService returns null', async () => { + const p = new GitHubRepoProvider( + createClient(makeRepoResponse()), + stubImageMeta(null), + ) + + const result = await p.fetch('mx-space/core') + + expect(result.thumbnailImage?.url).toBe('https://avatar.url') + expect(result.thumbnailImage?.blurhash).toBeUndefined() + expect(result.thumbnailImage?.palette).toBeUndefined() + expect(result.previewImage?.url).toMatch(/opengraph\.githubassets\.com/) + expect(result.previewImage?.blurhash).toBeUndefined() + }) }) }) From 5b8a12c9f2bc79205b0faabd4104d9f9e1a888fe Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 21:18:23 +0800 Subject: [PATCH 09/10] test(enrichment): add blurhash merge and fail-safe coverage to remaining github provider specs --- .../providers/github-commit.provider.spec.ts | 55 ++++++++++++++++ .../github-discussion.provider.spec.ts | 63 +++++++++++++++++++ .../providers/github-issue.provider.spec.ts | 59 +++++++++++++++++ .../providers/github-pr.provider.spec.ts | 59 +++++++++++++++++ 4 files changed, 236 insertions(+) diff --git a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts index 838adadb3cd..768344c350d 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts @@ -73,5 +73,60 @@ describe('GitHubCommitProvider', () => { expect(result.previewImage?.width).toBe(1280) expect(result.previewImage?.height).toBe(640) }) + + it('merges fetched blurhash/palette into thumbnail and preview images', async () => { + const mockData = { + html_url: 'https://github.com/mx-space/core/commit/abc123', + commit: { + message: 'Fix critical bug', + author: { date: '2023-06-01T00:00:00Z' }, + }, + author: { avatar_url: 'https://avatar', login: 'dev' }, + stats: { additions: 10, deletions: 5 }, + } + const meta = { + width: 64, + height: 64, + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + palette: { dominant: '#abcdef' }, + } + const p = new GitHubCommitProvider( + createClient(mockData), + stubImageMeta(meta), + ) + + const result = await p.fetch('mx-space/core/commits/abc123') + + expect(result.thumbnailImage?.blurhash).toBe(meta.blurhash) + expect(result.thumbnailImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.blurhash).toBe(meta.blurhash) + expect(result.previewImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) + }) + + it('omits blurhash when ImageMetaService returns null', async () => { + const mockData = { + html_url: 'https://github.com/mx-space/core/commit/abc123', + commit: { + message: 'Fix critical bug', + author: { date: '2023-06-01T00:00:00Z' }, + }, + author: { avatar_url: 'https://avatar', login: 'dev' }, + stats: { additions: 10, deletions: 5 }, + } + const p = new GitHubCommitProvider( + createClient(mockData), + stubImageMeta(null), + ) + + const result = await p.fetch('mx-space/core/commits/abc123') + + expect(result.thumbnailImage?.url).toBe('https://avatar') + expect(result.thumbnailImage?.blurhash).toBeUndefined() + expect(result.thumbnailImage?.palette).toBeUndefined() + expect(result.previewImage?.url).toMatch(/opengraph\.githubassets\.com/) + expect(result.previewImage?.blurhash).toBeUndefined() + }) }) }) diff --git a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts index 29ece8237f1..d7e3c158185 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-discussion.provider.spec.ts @@ -154,5 +154,68 @@ describe('GitHubDiscussionProvider', () => { 'Discussion not found', ) }) + + it('merges fetched blurhash/palette into thumbnail and preview images', async () => { + const mockData = { + repository: { + discussion: { + title: 'Feature Request', + body: 'Discussion body', + url: 'https://github.com/mx-space/core/discussions/42', + createdAt: '2023-06-01T00:00:00Z', + updatedAt: '2023-07-01T00:00:00Z', + author: { login: 'user', avatarUrl: 'https://avatar' }, + comments: { totalCount: 3 }, + }, + }, + } + const meta = { + width: 64, + height: 64, + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + palette: { dominant: '#abcdef' }, + } + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(meta), + ) + + const result = await p.fetch('mx-space/core/discussions/42') + + expect(result.thumbnailImage?.blurhash).toBe(meta.blurhash) + expect(result.thumbnailImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.blurhash).toBe(meta.blurhash) + expect(result.previewImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) + }) + + it('omits blurhash when ImageMetaService returns null', async () => { + const mockData = { + repository: { + discussion: { + title: 'Feature Request', + body: 'Discussion body', + url: 'https://github.com/mx-space/core/discussions/42', + createdAt: '2023-06-01T00:00:00Z', + updatedAt: '2023-07-01T00:00:00Z', + author: { login: 'user', avatarUrl: 'https://avatar' }, + comments: { totalCount: 3 }, + }, + }, + } + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(null), + ) + + const result = await p.fetch('mx-space/core/discussions/42') + + expect(result.thumbnailImage?.url).toBe('https://avatar') + expect(result.thumbnailImage?.blurhash).toBeUndefined() + expect(result.thumbnailImage?.palette).toBeUndefined() + expect(result.previewImage?.url).toMatch(/opengraph\.githubassets\.com/) + expect(result.previewImage?.blurhash).toBeUndefined() + }) }) }) diff --git a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts index d6e924366f4..c933c102d19 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-issue.provider.spec.ts @@ -105,5 +105,64 @@ describe('GitHubIssueProvider', () => { format: 'text', }) }) + + it('merges fetched blurhash/palette into thumbnail and preview images', async () => { + const mockData = { + number: 42, + title: 'Bug fix', + body: 'Body text', + html_url: 'https://github.com/mx-space/core/issues/42', + state: 'open', + comments: 5, + created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', + user: { avatar_url: 'https://avatar', login: 'testuser' }, + } + const meta = { + width: 64, + height: 64, + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + palette: { dominant: '#abcdef' }, + } + const p = new GitHubIssueProvider( + createClient(mockData), + stubImageMeta(meta), + ) + + const result = await p.fetch('mx-space/core/issues/42') + + expect(result.thumbnailImage?.blurhash).toBe(meta.blurhash) + expect(result.thumbnailImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.blurhash).toBe(meta.blurhash) + expect(result.previewImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) + }) + + it('omits blurhash when ImageMetaService returns null', async () => { + const mockData = { + number: 42, + title: 'Bug fix', + body: 'Body text', + html_url: 'https://github.com/mx-space/core/issues/42', + state: 'open', + comments: 5, + created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', + user: { avatar_url: 'https://avatar', login: 'testuser' }, + } + const p = new GitHubIssueProvider( + createClient(mockData), + stubImageMeta(null), + ) + + const result = await p.fetch('mx-space/core/issues/42') + + expect(result.thumbnailImage?.url).toBe('https://avatar') + expect(result.thumbnailImage?.blurhash).toBeUndefined() + expect(result.thumbnailImage?.palette).toBeUndefined() + expect(result.previewImage?.url).toMatch(/opengraph\.githubassets\.com/) + expect(result.previewImage?.blurhash).toBeUndefined() + }) }) }) diff --git a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts index 88e507b63c5..e5ab60a224a 100644 --- a/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts +++ b/apps/core/test/src/modules/enrichment/providers/github-pr.provider.spec.ts @@ -96,5 +96,64 @@ describe('GitHubPrProvider', () => { format: 'number', }) }) + + it('merges fetched blurhash/palette into thumbnail and preview images', async () => { + const mockData = { + number: 42, + title: 'Fix bug', + body: 'PR description', + html_url: 'https://github.com/mx-space/core/pull/42', + state: 'open', + merged: false, + created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', + user: { avatar_url: 'https://avatar', login: 'dev' }, + } + const meta = { + width: 64, + height: 64, + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + palette: { dominant: '#abcdef' }, + } + const p = new GitHubPrProvider( + createClient(mockData), + stubImageMeta(meta), + ) + + const result = await p.fetch('mx-space/core/pulls/42') + + expect(result.thumbnailImage?.blurhash).toBe(meta.blurhash) + expect(result.thumbnailImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.blurhash).toBe(meta.blurhash) + expect(result.previewImage?.palette).toEqual(meta.palette) + expect(result.previewImage?.width).toBe(1280) + expect(result.previewImage?.height).toBe(640) + }) + + it('omits blurhash when ImageMetaService returns null', async () => { + const mockData = { + number: 42, + title: 'Fix bug', + body: 'PR description', + html_url: 'https://github.com/mx-space/core/pull/42', + state: 'open', + merged: false, + created_at: '2023-06-01T00:00:00Z', + updated_at: '2023-07-01T00:00:00Z', + user: { avatar_url: 'https://avatar', login: 'dev' }, + } + const p = new GitHubPrProvider( + createClient(mockData), + stubImageMeta(null), + ) + + const result = await p.fetch('mx-space/core/pulls/42') + + expect(result.thumbnailImage?.url).toBe('https://avatar') + expect(result.thumbnailImage?.blurhash).toBeUndefined() + expect(result.thumbnailImage?.palette).toBeUndefined() + expect(result.previewImage?.url).toMatch(/opengraph\.githubassets\.com/) + expect(result.previewImage?.blurhash).toBeUndefined() + }) }) }) From 25dca7f3d86a00dfd4662b7d2e91d06ac3d4cd10 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 20 May 2026 21:58:11 +0800 Subject: [PATCH 10/10] fix(enrichment): split JSONB rename migration to avoid null patches --- .../database/migrations/0014_enrichment_captures.sql | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/core/src/database/migrations/0014_enrichment_captures.sql b/apps/core/src/database/migrations/0014_enrichment_captures.sql index 0f62fde9264..552692553c6 100644 --- a/apps/core/src/database/migrations/0014_enrichment_captures.sql +++ b/apps/core/src/database/migrations/0014_enrichment_captures.sql @@ -5,9 +5,9 @@ ALTER INDEX "enrichment_screenshots_lru_idx" RENAME TO "enrichment_captures_lru_ ALTER TABLE "enrichment_captures" RENAME CONSTRAINT "enrichment_screenshots_enrichment_id_enrichment_cache_id_fk" TO "enrichment_captures_enrichment_id_enrichment_cache_id_fk"; --> statement-breakpoint UPDATE "enrichment_cache" -SET "normalized" = ( - "normalized" - || jsonb_build_object('thumbnailImage', "normalized" -> 'image') - || jsonb_build_object('captureImage', "normalized" -> 'screenshot') - ) - 'image' - 'screenshot' -WHERE "normalized" ? 'image' OR "normalized" ? 'screenshot'; +SET "normalized" = jsonb_set("normalized", '{thumbnailImage}', "normalized" -> 'image') - 'image' +WHERE "normalized" ? 'image'; +--> statement-breakpoint +UPDATE "enrichment_cache" +SET "normalized" = jsonb_set("normalized", '{captureImage}', "normalized" -> 'screenshot') - 'screenshot' +WHERE "normalized" ? 'screenshot';