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..552692553c6 --- /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" = 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'; 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..fab6ea3a51c 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, + enrichmentCaptures, + type EnrichmentImagePalette, } 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: EnrichmentImagePalette | 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: EnrichmentImagePalette | 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?: EnrichmentImagePalette | 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..16354ce8e41 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,34 +157,34 @@ 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 + const captureConfig = openGraph?.screenshot return { used, cap: { - maxItems: Number(screenshot?.maxItems ?? DEFAULT_SCREENSHOT_MAX_ITEMS), + maxItems: Number(captureConfig?.maxItems ?? DEFAULT_CAPTURE_MAX_ITEMS), maxTotalBytes: Number( - screenshot?.maxTotalBytes ?? DEFAULT_SCREENSHOT_MAX_TOTAL_BYTES, + captureConfig?.maxTotalBytes ?? DEFAULT_CAPTURE_MAX_TOTAL_BYTES, ), }, - enabled: screenshot?.enabled === true, + enabled: captureConfig?.enabled === true, fetchMode: openGraph?.fetchMode ?? 'fetch', } } - @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..93cbfcf8dc0 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 @@ -16,14 +16,15 @@ 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' 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 +61,16 @@ const allProviders = [ controllers: [EnrichmentController], providers: [ EnrichmentRepository, - EnrichmentScreenshotRepository, + EnrichmentCaptureRepository, EnrichmentService, ProviderRegistry, UrlExtractorService, EnrichmentOriginGuard, BrowserSessionPool, BrowserFetchService, - ScreenshotPipelineService, - ScreenshotStorageService, + CapturePipelineService, + CaptureStorageService, + ImageMetaService, ...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..eedabad7a20 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 { + EnrichmentImage, 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: EnrichmentImage, ): 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..83c727fd218 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 { @@ -845,37 +845,37 @@ 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.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..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,32 +19,16 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentScreenshotPalette { - dominant: string - swatches?: string[] -} - -export interface EnrichmentScreenshot { - url: string - width: number - height: number - blurhash?: string - palette?: EnrichmentScreenshotPalette -} - export interface EnrichmentResult { - /** - * Cache row Snowflake id. Populated by `EnrichmentService` on cache hit - * and post-persist paths so consumers (notably the screenshot 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 - image?: EnrichmentImage + + thumbnailImage?: EnrichmentImage + + previewImage?: EnrichmentImage + url: string category: string @@ -53,13 +43,7 @@ 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. - */ - screenshot?: EnrichmentScreenshot + captureImage?: EnrichmentImage 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..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,9 +1,15 @@ 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 { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubCommitProvider implements EnrichmentProvider { @@ -15,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 @@ -60,14 +69,38 @@ 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, - image: 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: ogUrl, + ...ogMeta, + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + 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 a3d6e5587ad..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,9 +1,15 @@ 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 { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubDiscussionProvider implements EnrichmentProvider { @@ -15,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 @@ -43,6 +52,7 @@ export class GitHubDiscussionProvider implements EnrichmentProvider { body url createdAt + updatedAt author { login avatarUrl } comments { totalCount } } @@ -56,6 +66,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 +76,36 @@ 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, - image: discussion.author?.avatarUrl - ? { url: discussion.author.avatarUrl, alt: discussion.author.login } + thumbnailImage: avatarUrl + ? { + url: avatarUrl, + alt: discussion.author!.login, + ...avatarMeta, + } : undefined, + previewImage: { + url: ogUrl, + ...ogMeta, + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + 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 192e7dab901..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,9 +1,15 @@ 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 { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubIssueProvider implements EnrichmentProvider { @@ -15,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 @@ -73,12 +82,36 @@ 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, - image: 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: ogUrl, + ...ogMeta, + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + 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 31c454f2f7b..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,9 +1,15 @@ 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 { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubPrProvider implements EnrichmentProvider { @@ -15,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 @@ -82,12 +91,36 @@ 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, - image: 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: ogUrl, + ...ogMeta, + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + 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 40dfca549c7..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,9 +1,15 @@ 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 { GitHubClient } from './github.client' +import { + buildOgImageUrl, + GitHubClient, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, +} from './github.client' @Injectable() export class GitHubRepoProvider implements EnrichmentProvider { @@ -15,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 @@ -63,12 +72,34 @@ 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, - image: 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: ogUrl, + ...ogMeta, + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + 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/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) 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/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 79% 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..8ab742c8857 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', ) } @@ -299,11 +297,11 @@ export class ScreenshotStorageService { 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, ), } } @@ -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/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, 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..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.screenshotPipeline = screenshotPipeline - service.screenshotStorage = screenshotStorage + service.capturePipeline = capturePipeline + service.captureStorage = captureStorage 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-commit.provider.spec.ts b/apps/core/test/src/modules/enrichment/providers/github-commit.provider.spec.ts index d71d9d9a051..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 @@ -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,13 +53,80 @@ 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') 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) + }) + + 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 c4d5f009fe1..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 @@ -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( @@ -43,19 +52,31 @@ 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 }, }, }, } - const p = new GitHubDiscussionProvider(createClient(mockData)) + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(), + ) const result = await p.fetch('mx-space/core/discussions/42') 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.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', @@ -90,16 +111,20 @@ describe('GitHubDiscussionProvider', () => { body: null, url: 'https://github.com/mx-space/core/discussions/1', createdAt: null, + updatedAt: null, author: null, comments: { totalCount: 0 }, }, }, } - const p = new GitHubDiscussionProvider(createClient(mockData)) + const p = new GitHubDiscussionProvider( + createClient(mockData), + stubImageMeta(), + ) 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: '', @@ -111,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( @@ -119,11 +145,77 @@ 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', ) }) + + 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 7985bcfe19f..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 @@ -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( @@ -56,14 +62,24 @@ 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)) + const p = new GitHubIssueProvider(createClient(mockData), stubImageMeta()) const result = await p.fetch('mx-space/core/issues/42') 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', @@ -89,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 a6af1be67d4..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 @@ -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( @@ -48,14 +54,24 @@ 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)) + const p = new GitHubPrProvider(createClient(mockData), stubImageMeta()) const result = await p.fetch('mx-space/core/pulls/42') 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', @@ -80,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() + }) }) }) 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..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', @@ -11,6 +17,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, @@ -27,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( @@ -60,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) @@ -75,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') @@ -83,10 +94,15 @@ 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', }) + 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') @@ -129,15 +145,53 @@ describe('GitHubRepoProvider', () => { forks_count: null, }), ), + stubImageMeta(), ) const result = await p.fetch('mx-space/core') 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([]) }) + + 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() + }) }) }) 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() + }) +}) 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..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,31 +51,13 @@ export interface EnrichmentAttribute { format?: 'number' | 'rating' | 'date' | 'percent' | 'text' | 'duration' } -export interface EnrichmentScreenshotPalette { - dominant: string - swatches?: string[] -} - -export interface EnrichmentScreenshot { - url: string - width: number - height: number - blurhash?: string - palette?: EnrichmentScreenshotPalette -} - 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 +66,7 @@ export interface EnrichmentResult { attributes?: EnrichmentAttribute[] color?: string links?: Array<{ rel: string; url: string; label?: string }> - screenshot?: EnrichmentScreenshot + captureImage?: EnrichmentImage } /** diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 8055aa23e1b..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.2.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 bdebc84fa5f..6473e403b39 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 EnrichmentImagePalette { 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()), ], )