From 9573af91465c3151dfdca6c20759f014b2f4b7e6 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Mon, 29 Jun 2026 21:34:37 +0900 Subject: [PATCH] fix: validate Meilisearch provider readiness --- .changeset/search-meilisearch-readiness.md | 5 + README.md | 8 +- docs/package-catalog.json | 11 +- docs/package-docs-report.md | 4 +- docs/problem-code-registry.json | 143 +++++- .../api/problems-core/src/classes/Problem.md | 33 +- .../classes/MeilisearchDiagnosticsProvider.md | 60 +++ .../MeilisearchIndexNotFoundProblem.md | 294 ++++++++++++ .../MeilisearchInvalidRequestProblem.md | 298 ++++++++++++ .../MeilisearchRetryableUpstreamProblem.md | 298 ++++++++++++ .../MeilisearchTerminalUpstreamProblem.md | 298 ++++++++++++ .../MissingMeilisearchConfigProblem.md | 298 ++++++++++++ .../TenantTokenNotConfiguredProblem.md | 8 +- .../functions/isRetryableMeilisearchError.md | 24 + .../functions/normalizeMeilisearchError.md | 24 + .../MeilisearchDiagnosticsOptions.md | 24 + .../type-aliases/MeilisearchEngineOptions.md | 6 + .../MeilisearchReadinessCheckContext.md | 26 ++ .../MeilisearchReadinessCheckResult.md | 20 + .../MeilisearchTaskWaitOptions.md | 28 ++ .../docs/en/reference/extension-matrix.md | 2 +- .../en/reference/problem-recovery-cookbook.md | 94 +++- .../docs/en/reference/provider-maturity.md | 23 +- packages/search-meilisearch/README.md | 89 +++- packages/search-meilisearch/package.json | 1 + packages/search-meilisearch/src/index.ts | 12 +- .../src/libs/MeilisearchConfig.ts | 61 +++ .../libs/MeilisearchDiagnosticsProvider.ts | 175 +++++++ .../src/libs/MeilisearchEngine.ts | 290 ++++++++++-- .../src/libs/problems/MeilisearchProblems.ts | 351 +++++++++++++- packages/search-meilisearch/src/libs/types.ts | 10 + .../MeilisearchDiagnosticsProvider.spec.ts | 194 ++++++++ .../src/tests/MeilisearchEngine.spec.ts | 429 +++++++++++++++--- .../src/tests/MeilisearchLiveSmoke.spec.ts | 77 ++++ .../src/tests/MeilisearchProblems.spec.ts | 97 +++- packages/search-meilisearch/tsconfig.json | 10 +- packages/search-meilisearch/vitest.config.ts | 12 + pnpm-lock.yaml | 3 + public-api-surface.snapshot.json | 72 +++ 39 files changed, 3758 insertions(+), 154 deletions(-) create mode 100644 .changeset/search-meilisearch-readiness.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchDiagnosticsProvider.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchIndexNotFoundProblem.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchInvalidRequestProblem.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchRetryableUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchTerminalUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/classes/MissingMeilisearchConfigProblem.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/functions/isRetryableMeilisearchError.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/functions/normalizeMeilisearchError.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchDiagnosticsOptions.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckContext.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckResult.md create mode 100644 packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchTaskWaitOptions.md create mode 100644 packages/search-meilisearch/src/libs/MeilisearchConfig.ts create mode 100644 packages/search-meilisearch/src/libs/MeilisearchDiagnosticsProvider.ts create mode 100644 packages/search-meilisearch/src/tests/MeilisearchDiagnosticsProvider.spec.ts create mode 100644 packages/search-meilisearch/src/tests/MeilisearchLiveSmoke.spec.ts diff --git a/.changeset/search-meilisearch-readiness.md b/.changeset/search-meilisearch-readiness.md new file mode 100644 index 000000000..6a3b74fb5 --- /dev/null +++ b/.changeset/search-meilisearch-readiness.md @@ -0,0 +1,5 @@ +--- +"@croco/search-meilisearch": patch +--- + +Meilisearch search now publishes deterministic tenant-safe conformance, upstream Problem normalization, diagnostics, and env-gated live-smoke evidence before beta maturity. diff --git a/README.md b/README.md index 141504c04..06256f7b0 100644 --- a/README.md +++ b/README.md @@ -391,8 +391,8 @@ Adapter 경계와 공식 우선순위, compatibility certification checklist는 | 상태 | 의미 | 전체 public 패키지 수 | | ------------------- | ----------------------------------- | --------------------: | | 🟢 production-ready | 안정화, 적극 사용 권장 | 24 | -| 🟡 beta | 기능 완성, 실사용 검증 중 | 68 | -| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 17 | +| 🟡 beta | 기능 완성, 실사용 검증 중 | 69 | +| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 16 | | ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 | ### Extension & Adapter Matrix @@ -427,7 +427,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/onboarding-drizzle` | Onboarding | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | onboarding state persistence
step completion storage | 🟡 beta | has package tests | | `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🟡 beta | has package tests | -| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests | +| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens
search conformance
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | | `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests | | `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests | | `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | @@ -553,6 +553,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/metering-drizzle` | Provider | `packages/metering-drizzle` | README, API, tests | | `@croco/onboarding-drizzle` | Provider | `packages/onboarding-drizzle` | README, API, tests | | `@croco/search-drizzle` | Provider | `packages/search-drizzle` | README, API, tests | +| `@croco/search-meilisearch` | Provider | `packages/search-meilisearch` | README, API, tests | | `@croco/storage-cloudinary` | Provider | `packages/storage-cloudinary` | README, API, tests | | `@croco/storage-r2` | Provider | `packages/storage-r2` | README, API, tests | | `@croco/architecture-policy` | Tooling | `packages/architecture-policy` | README, API, tests | @@ -583,7 +584,6 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/metrics-billing` | Provider | `packages/metrics-billing` | README, API, tests | | `@croco/notifications-resend` | Provider | `packages/notifications-resend` | README, API, tests | | `@croco/ratelimit-upstash` | Provider | `packages/ratelimit-upstash` | README, API, tests | -| `@croco/search-meilisearch` | Provider | `packages/search-meilisearch` | README, API, tests | | `@croco/storage-cloudflare` | Provider | `packages/storage-cloudflare` | README, API, tests | | `@croco/tasks-qstash` | Provider | `packages/tasks-qstash` | README, API, tests | | `@croco/triggers-qstash` | Provider | `packages/triggers-qstash` | README, API, tests | diff --git a/docs/package-catalog.json b/docs/package-catalog.json index ad92ef3cb..3f65b97f9 100644 --- a/docs/package-catalog.json +++ b/docs/package-catalog.json @@ -237,6 +237,7 @@ "protocols-trpc", "rpc-codegen", "search-drizzle", + "search-meilisearch", "storage-cloudinary", "storage-core", "storage-r2", @@ -266,7 +267,6 @@ "metrics-billing", "notifications-resend", "ratelimit-upstash", - "search-meilisearch", "storage-cloudflare", "tasks-qstash", "triggers-qstash" @@ -601,7 +601,14 @@ "adapter": "Meilisearch engine", "runtimes": ["node", "lambda"], "requiredEnv": ["MEILISEARCH_HOST", "MEILISEARCH_API_KEY"], - "features": ["indexing", "search", "tenant tokens"] + "features": [ + "indexing", + "search", + "tenant tokens", + "search conformance", + "safe diagnostics", + "env-gated live smoke" + ] }, "storage-cloudflare": { "domain": "Storage", diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md index 4370ab433..9e3441e34 100644 --- a/docs/package-docs-report.md +++ b/docs/package-docs-report.md @@ -52,8 +52,8 @@ None. | Maturity | Packages | | ------------------- | -------: | | 🟢 production-ready | 24 | -| 🟡 beta | 68 | -| 🔴 alpha/WIP | 17 | +| 🟡 beta | 69 | +| 🔴 alpha/WIP | 16 | | ⚠️ deprecated | 0 | ## Extension Matrix diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 507b0e3da..ef9a05433 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 388, + "problemCount": 393, "problems": [ { "code": "ACCESS_DENIED", @@ -7886,6 +7886,114 @@ } ] }, + { + "code": "search-meilisearch/index-not-found", + "category": "NotFound", + "status": 404, + "title": "Not Found", + "cookbookPath": "/reference/problem-recovery-cookbook/#search-meilisearch-index-not-found", + "recovery": { + "cause": "The requested resource or route-visible record does not exist.", + "userAction": "Verify the identifier and refresh the resource list before retrying.", + "operatorAction": "Confirm tenant scoping, data retention, and backing-store lookup behavior.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", + "line": 80, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "search-meilisearch/invalid-request", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#search-meilisearch-invalid-request", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", + "line": 60, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "search-meilisearch/missing-config", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#search-meilisearch-missing-config", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", + "line": 39, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "search-meilisearch/retryable-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#search-meilisearch-retryable-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", + "line": 100, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "search-meilisearch/tenant-token-not-configured", "category": "InternalServerError", @@ -7907,9 +8015,36 @@ "sources": [ { "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", - "line": 7, - "column": 3, - "kind": "problem-class" + "line": 140, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "search-meilisearch/terminal-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#search-meilisearch-terminal-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts", + "line": 120, + "column": 5, + "kind": "problem-constructor" } ] }, diff --git a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md index 8f6414529..cf3813207 100644 --- a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md +++ b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md @@ -251,6 +251,11 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 - [`SearchCapabilityUnavailableProblem`](/api/search-core/src/classes/searchcapabilityunavailableproblem/) - [`StrategyUnavailableProblem`](/api/search-core/src/classes/strategyunavailableproblem/) - [`TransformNotFoundProblem`](/api/search-core/src/classes/transformnotfoundproblem/) +- [`MeilisearchIndexNotFoundProblem`](/api/search-meilisearch/src/classes/meilisearchindexnotfoundproblem/) +- [`MeilisearchInvalidRequestProblem`](/api/search-meilisearch/src/classes/meilisearchinvalidrequestproblem/) +- [`MeilisearchRetryableUpstreamProblem`](/api/search-meilisearch/src/classes/meilisearchretryableupstreamproblem/) +- [`MeilisearchTerminalUpstreamProblem`](/api/search-meilisearch/src/classes/meilisearchterminalupstreamproblem/) +- [`MissingMeilisearchConfigProblem`](/api/search-meilisearch/src/classes/missingmeilisearchconfigproblem/) - [`TenantTokenNotConfiguredProblem`](/api/search-meilisearch/src/classes/tenanttokennotconfiguredproblem/) - [`CloudflareImagesMissingConfigProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesmissingconfigproblem/) - [`CloudflareImagesRetryableUpstreamProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesretryableupstreamproblem/) @@ -304,7 +309,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 > `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) ---- +*** ### cause? @@ -314,31 +319,31 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.cause` ---- +*** ### code > `readonly` **code**: `string` ---- +*** ### detail? > `readonly` `optional` **detail?**: `string` ---- +*** ### extensions? > `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) ---- +*** ### instance? > `readonly` `optional` **instance?**: `string` ---- +*** ### message @@ -348,7 +353,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.message` ---- +*** ### name @@ -358,7 +363,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.name` ---- +*** ### stack? @@ -368,13 +373,13 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.stack` ---- +*** ### type > `readonly` **type**: `string` ---- +*** ### stackTraceLimit @@ -406,7 +411,7 @@ not capture any frames. `number` ---- +*** ### title @@ -428,7 +433,7 @@ not capture any frames. [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) ---- +*** ### captureStackTrace() @@ -441,7 +446,7 @@ a string representing the location in the code at which ```js const myObject = {}; Error.captureStackTrace(myObject); -myObject.stack; // Similar to `new Error().stack` +myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with @@ -496,7 +501,7 @@ a(); `Error.captureStackTrace` ---- +*** ### prepareStackTrace() diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchDiagnosticsProvider.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchDiagnosticsProvider.md new file mode 100644 index 000000000..b7401961d --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchDiagnosticsProvider.md @@ -0,0 +1,60 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchDiagnosticsProvider" +--- + +## Implements + +- [`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/) + +## Constructors + +### Constructor + +> **new MeilisearchDiagnosticsProvider**(`config`, `options?`): `MeilisearchDiagnosticsProvider` + +#### Parameters + +##### config + +`Partial`\<[`MeilisearchEngineOptions`](/api/search-meilisearch/src/type-aliases/meilisearchengineoptions/)\> + +##### options? + +[`MeilisearchDiagnosticsOptions`](/api/search-meilisearch/src/type-aliases/meilisearchdiagnosticsoptions/) = `{}` + +#### Returns + +`MeilisearchDiagnosticsProvider` + +## Properties + +### name + +> `readonly` **name**: `"search-meilisearch"` = `"search-meilisearch"` + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`name`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#name) + +## Methods + +### getHealth() + +> **getHealth**(`signal?`): `Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Parameters + +##### signal? + +`AbortSignal` + +#### Returns + +`Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`getHealth`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#gethealth) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchIndexNotFoundProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchIndexNotFoundProblem.md new file mode 100644 index 000000000..0a66beb7d --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchIndexNotFoundProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchIndexNotFoundProblem" +--- + +요청한 Meilisearch 인덱스를 찾을 수 없을 때 발생하는 Problem입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new MeilisearchIndexNotFoundProblem**(`context`): `MeilisearchIndexNotFoundProblem` + +#### Parameters + +##### context + +`MeilisearchErrorContext` + +#### Returns + +`MeilisearchIndexNotFoundProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +*** + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +*** + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +*** + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +*** + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +*** + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +*** + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +*** + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchInvalidRequestProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchInvalidRequestProblem.md new file mode 100644 index 000000000..512418087 --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchInvalidRequestProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchInvalidRequestProblem" +--- + +Meilisearch 요청 검증에 실패했을 때 발생하는 Problem입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new MeilisearchInvalidRequestProblem**(`context`, `detail?`): `MeilisearchInvalidRequestProblem` + +#### Parameters + +##### context + +`MeilisearchErrorContext` + +##### detail? + +`string` = `"Meilisearch request validation failed"` + +#### Returns + +`MeilisearchInvalidRequestProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +*** + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +*** + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +*** + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +*** + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +*** + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +*** + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +*** + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchRetryableUpstreamProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchRetryableUpstreamProblem.md new file mode 100644 index 000000000..a827264ff --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchRetryableUpstreamProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchRetryableUpstreamProblem" +--- + +재시도 가능한 Meilisearch 업스트림 실패를 나타내는 Problem입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new MeilisearchRetryableUpstreamProblem**(`context`, `message`): `MeilisearchRetryableUpstreamProblem` + +#### Parameters + +##### context + +`MeilisearchErrorContext` + +##### message + +`string` + +#### Returns + +`MeilisearchRetryableUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +*** + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +*** + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +*** + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +*** + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +*** + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +*** + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +*** + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchTerminalUpstreamProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchTerminalUpstreamProblem.md new file mode 100644 index 000000000..a58ff9d8e --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MeilisearchTerminalUpstreamProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchTerminalUpstreamProblem" +--- + +재시도할 수 없는 Meilisearch 업스트림 실패를 나타내는 Problem입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new MeilisearchTerminalUpstreamProblem**(`context`, `message`): `MeilisearchTerminalUpstreamProblem` + +#### Parameters + +##### context + +`MeilisearchErrorContext` + +##### message + +`string` + +#### Returns + +`MeilisearchTerminalUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +*** + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +*** + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +*** + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +*** + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +*** + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +*** + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +*** + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MissingMeilisearchConfigProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MissingMeilisearchConfigProblem.md new file mode 100644 index 000000000..9dd2f0af2 --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/MissingMeilisearchConfigProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "MissingMeilisearchConfigProblem" +--- + +필수 Meilisearch 설정이 누락되었을 때 발생하는 Problem입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new MissingMeilisearchConfigProblem**(`configKey`, `operation?`): `MissingMeilisearchConfigProblem` + +#### Parameters + +##### configKey + +`MeilisearchConfigKey` + +##### operation? + +`string` = `"configuration"` + +#### Returns + +`MissingMeilisearchConfigProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +*** + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +*** + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +*** + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +*** + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +*** + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +*** + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +*** + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/TenantTokenNotConfiguredProblem.md b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/TenantTokenNotConfiguredProblem.md index c09f07ab6..879d4d0bc 100644 --- a/packages/docs/src/content/docs/api/search-meilisearch/src/classes/TenantTokenNotConfiguredProblem.md +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/classes/TenantTokenNotConfiguredProblem.md @@ -29,9 +29,9 @@ title: "TenantTokenNotConfiguredProblem" ### category -> `readonly` **category**: [`InternalServerError`](/api/problems-core/src/enumerations/problemcategory/#internalservererror) = `ProblemCategory.InternalServerError` +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) -#### Overrides +#### Inherited from [`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) @@ -49,9 +49,9 @@ title: "TenantTokenNotConfiguredProblem" ### code -> `readonly` **code**: `"search-meilisearch/tenant-token-not-configured"` = `"search-meilisearch/tenant-token-not-configured"` +> `readonly` **code**: `string` -#### Overrides +#### Inherited from [`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/functions/isRetryableMeilisearchError.md b/packages/docs/src/content/docs/api/search-meilisearch/src/functions/isRetryableMeilisearchError.md new file mode 100644 index 000000000..72bb647d1 --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/functions/isRetryableMeilisearchError.md @@ -0,0 +1,24 @@ +--- +editUrl: false +next: false +prev: false +title: "isRetryableMeilisearchError" +--- + +> **isRetryableMeilisearchError**(`error`, `knownContext?`): `boolean` + +Meilisearch 오류가 재시도 가능한지 여부를 판별합니다. + +## Parameters + +### error + +`unknown` + +### knownContext? + +`MeilisearchErrorContext` + +## Returns + +`boolean` diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/functions/normalizeMeilisearchError.md b/packages/docs/src/content/docs/api/search-meilisearch/src/functions/normalizeMeilisearchError.md new file mode 100644 index 000000000..eac533002 --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/functions/normalizeMeilisearchError.md @@ -0,0 +1,24 @@ +--- +editUrl: false +next: false +prev: false +title: "normalizeMeilisearchError" +--- + +> **normalizeMeilisearchError**(`error`, `context`): [`Problem`](/api/problems-core/src/classes/problem/) + +Meilisearch 오류를 표준화된 Problem 인스턴스로 변환합니다. + +## Parameters + +### error + +`unknown` + +### context + +`MeilisearchErrorContext` + +## Returns + +[`Problem`](/api/problems-core/src/classes/problem/) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchDiagnosticsOptions.md b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchDiagnosticsOptions.md new file mode 100644 index 000000000..5460becfb --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchDiagnosticsOptions.md @@ -0,0 +1,24 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchDiagnosticsOptions" +--- + +> **MeilisearchDiagnosticsOptions** = `object` + +## Properties + +### readinessCheck? + +> `readonly` `optional` **readinessCheck?**: (`context`) => `Promise`\<[`MeilisearchReadinessCheckResult`](/api/search-meilisearch/src/type-aliases/meilisearchreadinesscheckresult/) \| `void`\> + +#### Parameters + +##### context + +[`MeilisearchReadinessCheckContext`](/api/search-meilisearch/src/type-aliases/meilisearchreadinesscheckcontext/) + +#### Returns + +`Promise`\<[`MeilisearchReadinessCheckResult`](/api/search-meilisearch/src/type-aliases/meilisearchreadinesscheckresult/) \| `void`\> diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchEngineOptions.md b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchEngineOptions.md index 1b50b38d2..63d9d5740 100644 --- a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchEngineOptions.md +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchEngineOptions.md @@ -23,6 +23,12 @@ Meilisearch 엔진 초기화 옵션입니다. *** +### taskWait? + +> `optional` **taskWait?**: [`MeilisearchTaskWaitOptions`](/api/search-meilisearch/src/type-aliases/meilisearchtaskwaitoptions/) + +*** + ### tenantTokenOptions? > `optional` **tenantTokenOptions?**: [`TenantTokenOptions`](/api/search-meilisearch/src/type-aliases/tenanttokenoptions/) diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckContext.md b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckContext.md new file mode 100644 index 000000000..a1351ecfb --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckContext.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchReadinessCheckContext" +--- + +> **MeilisearchReadinessCheckContext** = `object` + +## Properties + +### client + +> `readonly` **client**: `MeiliSearch` + +*** + +### config + +> `readonly` **config**: [`MeilisearchEngineOptions`](/api/search-meilisearch/src/type-aliases/meilisearchengineoptions/) + +*** + +### signal? + +> `readonly` `optional` **signal?**: `AbortSignal` diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckResult.md b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckResult.md new file mode 100644 index 000000000..af64479ca --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchReadinessCheckResult.md @@ -0,0 +1,20 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchReadinessCheckResult" +--- + +> **MeilisearchReadinessCheckResult** = `object` + +## Properties + +### details? + +> `readonly` `optional` **details?**: `Record`\<`string`, `unknown`\> + +*** + +### message? + +> `readonly` `optional` **message?**: `string` diff --git a/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchTaskWaitOptions.md b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchTaskWaitOptions.md new file mode 100644 index 000000000..18fe67f8a --- /dev/null +++ b/packages/docs/src/content/docs/api/search-meilisearch/src/type-aliases/MeilisearchTaskWaitOptions.md @@ -0,0 +1,28 @@ +--- +editUrl: false +next: false +prev: false +title: "MeilisearchTaskWaitOptions" +--- + +> **MeilisearchTaskWaitOptions** = `object` + +Meilisearch 비동기 task 완료 대기 옵션입니다. + +## Properties + +### enabled? + +> `optional` **enabled?**: `boolean` + +*** + +### intervalMs? + +> `optional` **intervalMs?**: `number` + +*** + +### timeoutMs? + +> `optional` **timeoutMs?**: `number` diff --git a/packages/docs/src/content/docs/en/reference/extension-matrix.md b/packages/docs/src/content/docs/en/reference/extension-matrix.md index a583599e3..e84caeed5 100644 --- a/packages/docs/src/content/docs/en/reference/extension-matrix.md +++ b/packages/docs/src/content/docs/en/reference/extension-matrix.md @@ -37,7 +37,7 @@ Runtime columns: Node covers long-running server and CLI use, Lambda covers serv | `@croco/onboarding-drizzle` | Onboarding | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | onboarding state persistence
step completion storage | 🟡 beta | has package tests | | `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🟡 beta | has package tests | -| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests | +| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens
search conformance
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | | `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests | | `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests | | `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index d3423cacb..4347006f5 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 388 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. +This cookbook documents 393 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. ## Index @@ -305,7 +305,12 @@ This cookbook documents 388 public Croco Problem codes. The deterministic JSON r | [`SEARCH_CAPABILITY_UNAVAILABLE`](#search-capability-unavailable) | NotImplemented | 501 | not-retryable | public | 1 | | [`SEARCH_DRIZZLE_INVALID_ROW`](#search-drizzle-invalid-row) | InternalServerError | 500 | conditional | operator-only | 1 | | [`search-core/transform-not-found`](#search-core-transform-not-found) | NotFound | 404 | not-retryable | public | 1 | +| [`search-meilisearch/index-not-found`](#search-meilisearch-index-not-found) | NotFound | 404 | not-retryable | public | 1 | +| [`search-meilisearch/invalid-request`](#search-meilisearch-invalid-request) | ValidationError | 422 | not-retryable | public | 1 | +| [`search-meilisearch/missing-config`](#search-meilisearch-missing-config) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`search-meilisearch/retryable-upstream`](#search-meilisearch-retryable-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | | [`search-meilisearch/tenant-token-not-configured`](#search-meilisearch-tenant-token-not-configured) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`search-meilisearch/terminal-upstream`](#search-meilisearch-terminal-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | | [`SEAT_LIMIT_EXCEEDED`](#seat-limit-exceeded) | Forbidden | 403 | not-retryable | safe-message | 1 | | [`SELF_IMPERSONATION_NOT_ALLOWED`](#self-impersonation-not-allowed) | Forbidden | 403 | not-retryable | safe-message | 1 | | [`starter/invalid-environment`](#starter-invalid-environment) | ValidationError | 422 | not-retryable | public | 1 | @@ -5366,6 +5371,74 @@ Sources: - `packages/search-core/src/libs/problems/SearchProblems.ts:17:3` (problem-class) + + +## `search-meilisearch/index-not-found` + +- Category: `NotFound` +- HTTP status: `404` Not Found +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The requested resource or route-visible record does not exist. +- User action: Verify the identifier and refresh the resource list before retrying. +- Operator action: Confirm tenant scoping, data retention, and backing-store lookup behavior. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:80:5` (problem-constructor) + + + +## `search-meilisearch/invalid-request` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:60:5` (problem-constructor) + + + +## `search-meilisearch/missing-config` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:39:5` (problem-constructor) + + + +## `search-meilisearch/retryable-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:100:5` (problem-constructor) + ## `search-meilisearch/tenant-token-not-configured` @@ -5381,7 +5454,24 @@ Sources: Sources: -- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:7:3` (problem-class) +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:140:5` (problem-constructor) + + + +## `search-meilisearch/terminal-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts:120:5` (problem-constructor) diff --git a/packages/docs/src/content/docs/en/reference/provider-maturity.md b/packages/docs/src/content/docs/en/reference/provider-maturity.md index 1f2cd4d02..f3a376a81 100644 --- a/packages/docs/src/content/docs/en/reference/provider-maturity.md +++ b/packages/docs/src/content/docs/en/reference/provider-maturity.md @@ -159,15 +159,26 @@ The current consumers are: | `@croco/onboarding-drizzle` | Uses the shared suite to check onboarding state schema, redacted readiness failures, transaction participation, composite tenant/user lookup, missing-state results, and duplicate upsert semantics. | Rollback and retryable failure gates remain owned by the caller's transaction and health-check boundaries. | | `@croco/search-drizzle` | Uses the shared suite to check caller-owned table requirements, redacted readiness failures, tenant propagation into search strategies, missing-tenant Problem mapping, unavailable capability errors, and strategy-unavailable errors. | PostgreSQL-specific live search behavior and migration evidence remain opt-in rather than default CI evidence. | +### Search provider conformance + +`@croco/search-meilisearch` currently uses a package-level conformance suite for the +`@croco/search-core` engine contract because a reusable search-provider harness is not yet shared +from `@croco/testing`. + +| Package | Harness evidence | Promotion result | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@croco/search-meilisearch` | Package tests cover index lifecycle, task waiting, document upsert/delete, query filters, pagination, sort, tenant isolation, tenant-token filters, missing config, invalid inputs, index-not-found, retryable upstream failures, terminal upstream failures, safe diagnostics, and env-gated live smoke skip behavior. | Promoted to beta. It has default contract and diagnostics evidence, but production-ready still requires recorded live Meilisearch smoke evidence with real credentials. | + ## First Promotion Wave No provider is promoted to production-ready by intent alone. -| Candidate | Current maturity | Evidence | Gate result | -| ------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@croco/storage-r2` | Beta | README, package tests, generated catalog entry, reusable storage conformance coverage with mocked R2 behavior, safe diagnostics/readiness, and env-gated optional live smoke. | Production gate still fails until optional live R2 smoke evidence with real credentials is recorded and reviewed. | -| `@croco/billing-polar` | Beta | README, package tests, generated catalog entry, reusable billing conformance coverage with mocked Polar behavior, stable Problem mapping, and safe diagnostics/readiness. | Production gate still fails until optional live Polar smoke evidence with real credentials is recorded and reviewed. | -| Upstash/QStash providers | Alpha | Shared conformance now covers `@croco/metering-upstash`, `@croco/ratelimit-upstash`, `@croco/batch-qstash`, `@croco/tasks-qstash`, and `@croco/triggers-qstash`. | Beta gate fails until all providers expose diagnostics/readiness and recorded real-backend plus Worker smoke evidence. | -| Drizzle SaaS providers | Beta | Package tests, generated API entrypoints, catalog entries, redacted Drizzle readiness evidence, and shared conformance consumers now cover `@croco/access-drizzle`, `@croco/audit-drizzle`, `@croco/auth-drizzle`, `@croco/customer-health-drizzle`, `@croco/entitlements-drizzle`, `@croco/execution-drizzle`, `@croco/invitation-drizzle`, `@croco/membership-drizzle`, `@croco/metering-drizzle`, `@croco/onboarding-drizzle`, and `@croco/search-drizzle`. | Production gate still fails until the documented unsupported transaction, rollback, tenant, validation, duplicate, conflict, retryable, migration, and live-backend smoke gates are closed or recorded as permanent contract limits. | +| Candidate | Current maturity | Evidence | Gate result | +| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@croco/storage-r2` | Beta | README, package tests, generated catalog entry, reusable storage conformance coverage with mocked R2 behavior, safe diagnostics/readiness, and env-gated optional live smoke. | Production gate still fails until optional live R2 smoke evidence with real credentials is recorded and reviewed. | +| `@croco/billing-polar` | Beta | README, package tests, generated catalog entry, reusable billing conformance coverage with mocked Polar behavior, stable Problem mapping, and safe diagnostics/readiness. | Production gate still fails until optional live Polar smoke evidence with real credentials is recorded and reviewed. | +| `@croco/search-meilisearch` | Beta | README, generated API docs, generated catalog entry, package-level search conformance, tenant-token tests, deterministic Meilisearch Problem normalization, safe diagnostics/readiness, and env-gated optional live smoke. | Production gate still fails until optional live Meilisearch smoke evidence with real credentials is recorded and reviewed. | +| Upstash/QStash providers | Alpha | Shared conformance now covers `@croco/metering-upstash`, `@croco/ratelimit-upstash`, `@croco/batch-qstash`, `@croco/tasks-qstash`, and `@croco/triggers-qstash`. | Beta gate fails until all providers expose diagnostics/readiness and recorded real-backend plus Worker smoke evidence. | +| Drizzle SaaS providers | Beta | Package tests, generated API entrypoints, catalog entries, redacted Drizzle readiness evidence, and shared conformance consumers now cover `@croco/access-drizzle`, `@croco/audit-drizzle`, `@croco/auth-drizzle`, `@croco/customer-health-drizzle`, `@croco/entitlements-drizzle`, `@croco/execution-drizzle`, `@croco/invitation-drizzle`, `@croco/membership-drizzle`, `@croco/metering-drizzle`, `@croco/onboarding-drizzle`, and `@croco/search-drizzle`. | Production gate still fails until the documented unsupported transaction, rollback, tenant, validation, duplicate, conflict, retryable, migration, and live-backend smoke gates are closed or recorded as permanent contract limits. | This page should be updated whenever a provider changes maturity in `docs/package-catalog.json`. diff --git a/packages/search-meilisearch/README.md b/packages/search-meilisearch/README.md index 45bbf6303..2e210abf4 100644 --- a/packages/search-meilisearch/README.md +++ b/packages/search-meilisearch/README.md @@ -11,6 +11,7 @@ pnpm add @croco/search-meilisearch meilisearch ## 사용법 ```typescript +import { Context } from "@croco/framework-context"; import { MeilisearchEngine } from "@croco/search-meilisearch"; const engine = new MeilisearchEngine({ @@ -22,21 +23,93 @@ const engine = new MeilisearchEngine({ }, }); -await engine.indexDocument("products", { id: "p1", name: "Croco Hoodie" }); -const result = await engine.search("products", { query: "hoodie" }); +await Context.run({ requestId: "req-1", tenantId: "tenant-1" }, async () => { + await engine.createIndex({ + name: "products", + filterableFields: ["category"], + searchableFields: ["name"], + }); + await engine.indexDocument("products", { + id: "p1", + tenantId: "tenant-1", + category: "apparel", + name: "Croco Hoodie", + }); + const result = await engine.search("products", { + filters: { category: "apparel" }, + query: "hoodie", + }); +}); ``` ## API 레퍼런스 -| API | 설명 | -| --------------------------------- | ---------------------------------------------------------------- | -| `MeilisearchEngine` | 검색, 인덱싱, 삭제, 인덱스 생성, tenant token 발급을 담당합니다. | -| `MeilisearchEngineOptions` | host, apiKey, tenant token 옵션을 지정합니다. | -| `TenantTokenOptions` | tenant token용 API key UID와 만료 시간을 지정합니다. | -| `TenantTokenNotConfiguredProblem` | tenant token 옵션 없이 토큰 발급을 시도할 때 발생합니다. | +| API | 설명 | +| ------------------------------------- | ---------------------------------------------------------------- | +| `MeilisearchEngine` | 검색, 인덱싱, 삭제, 인덱스 생성, tenant token 발급을 담당합니다. | +| `MeilisearchDiagnosticsProvider` | 설정과 optional live readiness를 secret 없이 진단합니다. | +| `MeilisearchEngineOptions` | host, apiKey, tenant token, task 대기 옵션을 지정합니다. | +| `TenantTokenOptions` | tenant token용 API key UID와 만료 시간을 지정합니다. | +| `MissingMeilisearchConfigProblem` | host/API key 설정 누락을 나타냅니다. | +| `MeilisearchInvalidRequestProblem` | 안전하지 않은 필터/정렬 필드, 빈 index/document id를 나타냅니다. | +| `MeilisearchIndexNotFoundProblem` | upstream index-not-found를 안정적인 Problem으로 정규화합니다. | +| `MeilisearchRetryableUpstreamProblem` | timeout/429/5xx 등 재시도 가능한 upstream 장애를 나타냅니다. | +| `MeilisearchTerminalUpstreamProblem` | 인증 실패 등 terminal upstream 장애를 나타냅니다. | +| `TenantTokenNotConfiguredProblem` | tenant token 옵션 없이 토큰 발급을 시도할 때 발생합니다. | ## 동작 메모 - 모든 검색과 인덱싱은 현재 `Context.getTenantId()` 값을 `_tenantId` 필드에 반영합니다. - tenant token은 `_tenantId` 필터 규칙을 포함해 생성됩니다. - tenant 정보가 없으면 `MissingTenantProblem`이 발생합니다. +- `createIndex`, `indexDocument`, `bulkIndex`, `deleteDocument`, `deleteIndex`는 기본적으로 + Meilisearch task 완료를 기다린 뒤 resolve합니다. 필요하면 `taskWait.enabled: false`로 + enqueue-only 동작을 선택할 수 있습니다. +- 필터와 정렬 필드는 `A-Z`, `a-z`, 숫자, `_`, `.`, `-`만 허용합니다. 문자열 필터 값은 + quote/backslash를 escape해 tenant filter injection을 막습니다. + +## 런타임과 설정 + +| 항목 | 값 | +| --------------------- | -------------------------------------------------------------- | +| Runtime | Node.js, Lambda | +| Required env | `MEILISEARCH_HOST`, `MEILISEARCH_API_KEY` | +| Optional tenant token | `tenantTokenOptions.apiKeyUid`, `tenantTokenOptions.expiresIn` | + +`MeilisearchDiagnosticsProvider`는 설정 존재 여부만 boolean으로 노출하고 raw host/API key를 +출력하지 않습니다. Live readiness는 명시적으로 `readinessCheck`를 넘겼을 때만 실행됩니다. + +```typescript +import { MeilisearchDiagnosticsProvider } from "@croco/search-meilisearch"; + +const diagnostics = new MeilisearchDiagnosticsProvider( + { + host: process.env.MEILISEARCH_HOST, + apiKey: process.env.MEILISEARCH_API_KEY, + }, + { + readinessCheck: async ({ client }) => { + await client.health(); + return { details: { reachable: true } }; + }, + }, +); + +const health = await diagnostics.getHealth(); +``` + +## 검증 + +Default tests do not require a live Meilisearch service: + +```bash +pnpm --filter @croco/search-meilisearch test +``` + +Optional live smoke runs only when both env vars are present: + +```bash +MEILISEARCH_HOST=http://localhost:7700 \ +MEILISEARCH_API_KEY=masterKey \ +pnpm --filter @croco/search-meilisearch test -- MeilisearchLiveSmoke +``` diff --git a/packages/search-meilisearch/package.json b/packages/search-meilisearch/package.json index f18835b66..498b6b2a0 100644 --- a/packages/search-meilisearch/package.json +++ b/packages/search-meilisearch/package.json @@ -36,6 +36,7 @@ "test": "vitest run" }, "dependencies": { + "@croco/diagnostics-core": "workspace:*", "@croco/framework-context": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/search-core": "workspace:*", diff --git a/packages/search-meilisearch/src/index.ts b/packages/search-meilisearch/src/index.ts index 675345a35..d794e816d 100644 --- a/packages/search-meilisearch/src/index.ts +++ b/packages/search-meilisearch/src/index.ts @@ -2,11 +2,21 @@ * Meilisearch 기반 검색 엔진 구현체를 내보냅니다. */ export * from "./libs/MeilisearchEngine"; +export * from "./libs/MeilisearchDiagnosticsProvider"; /** * 테넌트 토큰 설정 관련 Problem을 내보냅니다. */ -export { TenantTokenNotConfiguredProblem } from "./libs/problems/MeilisearchProblems"; +export { + MeilisearchIndexNotFoundProblem, + MeilisearchInvalidRequestProblem, + MeilisearchRetryableUpstreamProblem, + MeilisearchTerminalUpstreamProblem, + MissingMeilisearchConfigProblem, + TenantTokenNotConfiguredProblem, + isRetryableMeilisearchError, + normalizeMeilisearchError, +} from "./libs/problems/MeilisearchProblems"; /** * Meilisearch 연결 설정 타입을 내보냅니다. diff --git a/packages/search-meilisearch/src/libs/MeilisearchConfig.ts b/packages/search-meilisearch/src/libs/MeilisearchConfig.ts new file mode 100644 index 000000000..c4d43d85b --- /dev/null +++ b/packages/search-meilisearch/src/libs/MeilisearchConfig.ts @@ -0,0 +1,61 @@ +import { + MissingMeilisearchConfigProblem, + MeilisearchInvalidRequestProblem, +} from "./problems/MeilisearchProblems"; +import type { MeilisearchEngineOptions } from "./types"; + +export function validateMeilisearchOptions( + options: Partial, +): MeilisearchEngineOptions { + if (!isNonEmptyString(options.host)) { + throw new MissingMeilisearchConfigProblem("host"); + } + + if (!isNonEmptyString(options.apiKey)) { + throw new MissingMeilisearchConfigProblem("apiKey"); + } + + if (options.tenantTokenOptions && !isNonEmptyString(options.tenantTokenOptions.apiKeyUid)) { + throw new MissingMeilisearchConfigProblem("tenantTokenOptions.apiKeyUid"); + } + + validatePositiveInteger(options.taskWait?.timeoutMs, "taskWait.timeoutMs"); + validatePositiveInteger(options.taskWait?.intervalMs, "taskWait.intervalMs"); + + return options as MeilisearchEngineOptions; +} + +export function createSafeMeilisearchConfigDetails( + options: Partial, +): Record { + return { + provider: "meilisearch", + hasHost: isNonEmptyString(options.host), + hasApiKey: isNonEmptyString(options.apiKey), + hasTenantTokenOptions: options.tenantTokenOptions !== undefined, + hasTenantTokenApiKeyUid: isNonEmptyString(options.tenantTokenOptions?.apiKeyUid), + taskWaitEnabled: options.taskWait?.enabled ?? true, + }; +} + +function validatePositiveInteger(value: unknown, label: string): void { + if (value === undefined) { + return; + } + + if ( + typeof value !== "number" || + !Number.isFinite(value) || + !Number.isInteger(value) || + value <= 0 + ) { + throw new MeilisearchInvalidRequestProblem({ + operation: "configuration", + upstreamCode: `invalid-${label}`, + }); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/packages/search-meilisearch/src/libs/MeilisearchDiagnosticsProvider.ts b/packages/search-meilisearch/src/libs/MeilisearchDiagnosticsProvider.ts new file mode 100644 index 000000000..a287e785e --- /dev/null +++ b/packages/search-meilisearch/src/libs/MeilisearchDiagnosticsProvider.ts @@ -0,0 +1,175 @@ +import type { DiagnosticsProvider, HealthStatus } from "@croco/diagnostics-core"; +import { Problem } from "@croco/problems-core"; +import { MeiliSearch } from "meilisearch"; +import { + createSafeMeilisearchConfigDetails, + validateMeilisearchOptions, +} from "./MeilisearchConfig"; +import { normalizeMeilisearchError } from "./problems/MeilisearchProblems"; +import type { MeilisearchEngineOptions } from "./types"; + +export type MeilisearchReadinessCheckContext = { + readonly client: MeiliSearch; + readonly config: MeilisearchEngineOptions; + readonly signal?: AbortSignal; +}; + +export type MeilisearchReadinessCheckResult = { + readonly details?: Record; + readonly message?: string; +}; + +export type MeilisearchDiagnosticsOptions = { + readonly readinessCheck?: ( + context: MeilisearchReadinessCheckContext, + ) => Promise; +}; + +export class MeilisearchDiagnosticsProvider implements DiagnosticsProvider { + readonly name = "search-meilisearch"; + + constructor( + private readonly config: Partial, + private readonly options: MeilisearchDiagnosticsOptions = {}, + ) {} + + async getHealth(signal?: AbortSignal): Promise { + const baseDetails = createSafeMeilisearchConfigDetails(this.config); + let validConfig: MeilisearchEngineOptions; + + try { + validConfig = validateMeilisearchOptions(this.config); + } catch (error) { + const problem = + error instanceof Problem + ? error + : normalizeMeilisearchError(error, { operation: "configuration" }); + + return { + status: "unhealthy", + component: this.name, + message: sanitizeDiagnosticMessage(problem.detail), + details: { + ...baseDetails, + liveCheck: "not_started", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + + if (!this.options.readinessCheck) { + return { + status: "healthy", + component: this.name, + message: + "Meilisearch configuration is present; live upstream readiness check is not configured", + details: { + ...baseDetails, + liveCheck: "not_configured", + }, + lastChecked: new Date().toISOString(), + }; + } + + try { + const client = new MeiliSearch({ + host: validConfig.host, + apiKey: validConfig.apiKey, + }); + const result = await this.options.readinessCheck({ client, config: validConfig, signal }); + + return { + status: "healthy", + component: this.name, + message: sanitizeDiagnosticMessage(result?.message ?? "Meilisearch readiness check passed"), + details: { + ...baseDetails, + liveCheck: "passed", + ...(result?.details && { readiness: sanitizeDiagnosticValue(result.details) }), + }, + lastChecked: new Date().toISOString(), + }; + } catch (error) { + const problem = normalizeMeilisearchError(error, { operation: "readiness" }); + + return { + status: "degraded", + component: this.name, + message: sanitizeDiagnosticMessage(problem.detail), + details: { + ...baseDetails, + ...sanitizeDiagnosticDetails(problem.extensions), + liveCheck: "failed", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + } +} + +const SENSITIVE_DIAGNOSTIC_KEY_PATTERN = + "authorization|cookie|password|secret|token|api[-_]?key|access[-_]?key|access[-_]?token"; +const SENSITIVE_DIAGNOSTIC_KEY = new RegExp(`(${SENSITIVE_DIAGNOSTIC_KEY_PATTERN})`, "i"); +const SENSITIVE_DIAGNOSTIC_QUERY_PARAM = new RegExp( + `([?&](${SENSITIVE_DIAGNOSTIC_KEY_PATTERN})=)[^&#\\s]+`, + "gi", +); +const SENSITIVE_DIAGNOSTIC_ASSIGNMENT = new RegExp( + `(["']?\\b(${SENSITIVE_DIAGNOSTIC_KEY_PATTERN})\\b["']?\\s*[:=]\\s*)(["']?)([^"',\\s;&}]+)\\3`, + "gi", +); +const SENSITIVE_DIAGNOSTIC_HEADER = /\b(authorization|cookie)(\s*[:=]\s*)[^,\n;]+/gi; + +function sanitizeDiagnosticDetails(value: unknown): Record { + const sanitized = sanitizeDiagnosticValue(value); + return typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized) + ? (sanitized as Record) + : {}; +} + +function sanitizeDiagnosticMessage(value: string | undefined): string | undefined { + return value === undefined ? undefined : redactDiagnosticText(value); +} + +function sanitizeDiagnosticValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeDiagnosticValue(item)); + } + + if (typeof value === "string") { + return redactDiagnosticText(value); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof Error) { + return { + name: value.name, + }; + } + + if (typeof value === "object" && value !== null) { + const sanitized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + sanitized[key] = SENSITIVE_DIAGNOSTIC_KEY.test(key) + ? "[redacted]" + : sanitizeDiagnosticValue(nestedValue); + } + return sanitized; + } + + return value; +} + +function redactDiagnosticText(value: string): string { + return value + .replace(SENSITIVE_DIAGNOSTIC_HEADER, "$1$2[redacted]") + .replace(SENSITIVE_DIAGNOSTIC_QUERY_PARAM, "$1[redacted]") + .replace(SENSITIVE_DIAGNOSTIC_ASSIGNMENT, "$1$3[redacted]$3"); +} diff --git a/packages/search-meilisearch/src/libs/MeilisearchEngine.ts b/packages/search-meilisearch/src/libs/MeilisearchEngine.ts index 79b789e59..3b60dbd7a 100644 --- a/packages/search-meilisearch/src/libs/MeilisearchEngine.ts +++ b/packages/search-meilisearch/src/libs/MeilisearchEngine.ts @@ -8,7 +8,12 @@ import type { } from "@croco/search-core"; import { MissingTenantProblem, SearchEngine } from "@croco/search-core"; import { MeiliSearch } from "meilisearch"; -import { TenantTokenNotConfiguredProblem } from "./problems/MeilisearchProblems"; +import { validateMeilisearchOptions } from "./MeilisearchConfig"; +import { + MeilisearchInvalidRequestProblem, + normalizeMeilisearchError, + TenantTokenNotConfiguredProblem, +} from "./problems/MeilisearchProblems"; import type { MeilisearchEngineOptions } from "./types"; @Component() @@ -18,6 +23,8 @@ import type { MeilisearchEngineOptions } from "./types"; export class MeilisearchEngine extends SearchEngine { private readonly client: MeiliSearch; private static readonly FILTER_ESCAPE_REGEXP = /([\\"])/g; + private static readonly FILTER_FIELD_REGEXP = /^[A-Za-z_][A-Za-z0-9_.-]*$/; + private readonly options: MeilisearchEngineOptions; readonly capabilities: SearchEngineCapabilities = { facetedSearch: true, @@ -26,29 +33,36 @@ export class MeilisearchEngine extends SearchEngine { fuzzySearch: true, }; - constructor(private readonly options: MeilisearchEngineOptions) { + constructor(options: MeilisearchEngineOptions) { super(); + this.options = validateMeilisearchOptions(options); this.client = new MeiliSearch({ - host: options.host, - apiKey: options.apiKey, + host: this.options.host, + apiKey: this.options.apiKey, }); } async search(indexName: string, query: SearchQuery): Promise> { + this.validateIndexName(indexName, "search"); const tenantId = this.getTenantId("search"); - const filterArray = this.transformFilters(query.filters); + const filterArray = this.transformFilters(query.filters, tenantId); filterArray.push(`_tenantId = "${this.escapeFilterValue(tenantId)}"`); const sortArray = this.transformSort(query.sort); const index = this.client.index(indexName); - const result = await index.search(query.query, { - filter: filterArray, - limit: query.limit, - offset: query.offset, - sort: sortArray, - }); + const result = await this.runOperation( + "search", + () => + index.search(query.query, { + filter: filterArray, + limit: query.limit, + offset: query.offset, + sort: sortArray, + }), + { indexName }, + ); return { hits: result.hits.map((h) => ({ document: h as T })), @@ -59,46 +73,107 @@ export class MeilisearchEngine extends SearchEngine { } async indexDocument(indexName: string, document: SearchDocument): Promise { + this.validateIndexName(indexName, "indexDocument"); + this.validateDocument(document, "indexDocument"); const tenantId = this.getTenantId("indexDocument"); const index = this.client.index(indexName); - await index.addDocuments([{ ...document, _tenantId: tenantId }]); + const task = await this.runOperation( + "indexDocument", + () => index.addDocuments([{ ...document, tenantId, _tenantId: tenantId }]), + { documentId: document.id, indexName }, + ); + await this.waitForTask("indexDocument", task, { documentId: document.id, indexName }); } async bulkIndex(indexName: string, documents: SearchDocument[]): Promise { + this.validateIndexName(indexName, "bulkIndex"); + for (const document of documents) { + this.validateDocument(document, "bulkIndex"); + } + + if (documents.length === 0) { + return; + } + const tenantId = this.getTenantId("bulkIndex"); const index = this.client.index(indexName); - const docsWithTenant = documents.map((doc) => ({ ...doc, _tenantId: tenantId })); - await index.addDocuments(docsWithTenant); + const docsWithTenant = documents.map((doc) => ({ ...doc, tenantId, _tenantId: tenantId })); + const task = await this.runOperation("bulkIndex", () => index.addDocuments(docsWithTenant), { + indexName, + }); + await this.waitForTask("bulkIndex", task, { indexName }); } async deleteDocument(indexName: string, documentId: string): Promise { + this.validateIndexName(indexName, "deleteDocument"); + this.validateDocumentId(documentId, "deleteDocument"); const tenantId = this.getTenantId("deleteDocument"); const index = this.client.index(indexName); - await index.deleteDocuments({ - filter: `_tenantId = "${this.escapeFilterValue(tenantId)}" AND id = "${this.escapeFilterValue(documentId)}"`, - }); + const task = await this.runOperation( + "deleteDocument", + () => + index.deleteDocuments({ + filter: `_tenantId = "${this.escapeFilterValue(tenantId)}" AND id = "${this.escapeFilterValue(documentId)}"`, + }), + { documentId, indexName }, + ); + await this.waitForTask("deleteDocument", task, { documentId, indexName }); } async createIndex(config: IndexConfig): Promise { - await this.client.createIndex(config.name, { primaryKey: config.primaryKey || "id" }); + this.validateIndexName(config.name, "createIndex"); + this.validateAttributeNames(config.filterableFields, "createIndex"); + this.validateAttributeNames(config.searchableFields, "createIndex"); + this.validateAttributeNames(config.sortableFields, "createIndex"); + + const createTask = await this.runOperation( + "createIndex", + () => this.client.createIndex(config.name, { primaryKey: config.primaryKey || "id" }), + { indexName: config.name }, + ); + await this.waitForTask("createIndex", createTask, { indexName: config.name }); const index = this.client.index(config.name); - const filterable = ["_tenantId", ...(config.filterableFields || [])]; + const filterable = ["_tenantId", "id", ...(config.filterableFields || [])].filter( + (field, index, fields) => fields.indexOf(field) === index, + ); const sortable = config.sortableFields || []; - - await index.updateSettings({ + const settings = { filterableAttributes: filterable, + ...(config.searchableFields !== undefined && { + searchableAttributes: config.searchableFields, + }), sortableAttributes: sortable, + }; + + const settingsTask = await this.runOperation( + "createIndex.updateSettings", + () => index.updateSettings(settings), + { indexName: config.name }, + ); + await this.waitForTask("createIndex.updateSettings", settingsTask, { + indexName: config.name, }); } async deleteIndex(name: string): Promise { - await this.client.deleteIndex(name); + this.validateIndexName(name, "deleteIndex"); + const task = await this.runOperation("deleteIndex", () => this.client.deleteIndex(name), { + indexName: name, + }); + await this.waitForTask("deleteIndex", task, { indexName: name }); } async generateTenantToken(tenantId: string, expiresAt?: Date): Promise { + if (!tenantId.trim()) { + throw new MeilisearchInvalidRequestProblem( + { operation: "generateTenantToken" }, + "Tenant id must be a non-empty string", + ); + } + if (!this.options.tenantTokenOptions) { throw new TenantTokenNotConfiguredProblem(); } @@ -111,11 +186,16 @@ export class MeilisearchEngine extends SearchEngine { }, }; - return await this.client.generateTenantToken(apiKeyUid, searchRules, { - expiresAt: - expiresAt ?? - (expiresIn !== undefined ? new Date(Date.now() + expiresIn * 1000) : undefined), - }); + return await this.runOperation( + "generateTenantToken", + () => + this.client.generateTenantToken(apiKeyUid, searchRules, { + expiresAt: + expiresAt ?? + (expiresIn !== undefined ? new Date(Date.now() + expiresIn * 1000) : undefined), + }), + {}, + ); } private getTenantId(operation: string): string { @@ -126,20 +206,166 @@ export class MeilisearchEngine extends SearchEngine { return tenantId; } - private transformFilters(filters?: Record): string[] { + private transformFilters( + filters: Record | undefined, + tenantId: string, + ): string[] { if (!filters) return []; - return Object.entries(filters).map(([key, value]) => { - if (typeof value === "string") return `${key} = "${this.escapeFilterValue(value)}"`; - return `${key} = ${value}`; + return Object.entries(filters).flatMap(([key, value]) => { + if (key === "tenantId") { + if (value !== tenantId) { + throw new MeilisearchInvalidRequestProblem( + { operation: "search", upstreamCode: "invalid-tenant-filter" }, + "Meilisearch tenantId filter must match the active tenant context", + ); + } + return []; + } + + if (key === "_tenantId") { + throw new MeilisearchInvalidRequestProblem( + { operation: "search", upstreamCode: "invalid-tenant-filter" }, + "Meilisearch _tenantId filter is provider-owned and cannot be supplied by callers", + ); + } + + this.validateAttributeName(key, "search"); + return [`${key} = ${this.formatFilterValue(value, "search")}`]; }); } + private formatFilterValue(value: unknown, operation: string): string { + if (typeof value === "string") { + return `"${this.escapeFilterValue(value)}"`; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + if (typeof value === "boolean") { + return String(value); + } + + throw new MeilisearchInvalidRequestProblem( + { operation }, + "Meilisearch filters support only string, finite number, and boolean values", + ); + } + private escapeFilterValue(value: string): string { return value.replace(MeilisearchEngine.FILTER_ESCAPE_REGEXP, "\\$1"); } private transformSort(sort?: { field: string; order: "asc" | "desc" }[]): string[] | undefined { if (!sort) return undefined; - return sort.map((s) => `${s.field}:${s.order}`); + return sort.map((s) => { + this.validateAttributeName(s.field, "search"); + return `${s.field}:${s.order}`; + }); + } + + private validateIndexName(indexName: string, operation: string): void { + if (!indexName.trim()) { + throw new MeilisearchInvalidRequestProblem( + { operation }, + "Meilisearch index name must be a non-empty string", + ); + } + } + + private validateDocument(document: SearchDocument, operation: string): void { + this.validateDocumentId(document.id, operation); + } + + private validateDocumentId(documentId: string, operation: string): void { + if (!documentId.trim()) { + throw new MeilisearchInvalidRequestProblem( + { operation }, + "Search document id must be a non-empty string", + ); + } + } + + private validateAttributeNames(fields: readonly string[] | undefined, operation: string): void { + for (const field of fields ?? []) { + this.validateAttributeName(field, operation); + } + } + + private validateAttributeName(field: string, operation: string): void { + if (!MeilisearchEngine.FILTER_FIELD_REGEXP.test(field)) { + throw new MeilisearchInvalidRequestProblem( + { operation, upstreamCode: "invalid-filter-field" }, + `Meilisearch field '${field}' is not safe for filter or sort construction`, + ); + } + } + + private async runOperation( + operation: string, + action: () => Promise | T, + context: { indexName?: string; documentId?: string }, + ): Promise { + try { + return await action(); + } catch (error) { + throw normalizeMeilisearchError(error, { operation, ...context }); + } + } + + private async waitForTask( + operation: string, + task: unknown, + context: { indexName?: string; documentId?: string }, + ): Promise { + if (this.options.taskWait?.enabled === false) { + return; + } + + const taskUid = this.getTaskUid(task); + if (taskUid === undefined) { + throw new MeilisearchInvalidRequestProblem( + { operation, ...context, upstreamCode: "missing-task-uid" }, + "Meilisearch task response is missing taskUid", + ); + } + + const result = await this.runOperation( + `${operation}.waitForTask`, + () => + this.client.waitForTask(taskUid, { + ...(this.options.taskWait?.timeoutMs !== undefined && { + timeOutMs: this.options.taskWait.timeoutMs, + }), + ...(this.options.taskWait?.intervalMs !== undefined && { + intervalMs: this.options.taskWait.intervalMs, + }), + }), + context, + ); + + if (this.isFailedTask(result)) { + throw normalizeMeilisearchError(result.error, { operation, ...context }); + } + } + + private getTaskUid(task: unknown): number | undefined { + if (typeof task !== "object" || task === null || !("taskUid" in task)) { + return undefined; + } + + const taskUid = task.taskUid; + return typeof taskUid === "number" && Number.isInteger(taskUid) ? taskUid : undefined; + } + + private isFailedTask(task: unknown): task is { readonly error: unknown } { + return ( + typeof task === "object" && + task !== null && + "status" in task && + task.status === "failed" && + "error" in task + ); } } diff --git a/packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts b/packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts index 00cf5c1ad..4f37aefe6 100644 --- a/packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts +++ b/packages/search-meilisearch/src/libs/problems/MeilisearchProblems.ts @@ -1,12 +1,357 @@ import { Problem, ProblemCategory } from "@croco/problems-core"; +const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); +const RETRYABLE_ERROR_CODES = new Set([ + "ECONNABORTED", + "ECONNREFUSED", + "ECONNRESET", + "EAI_AGAIN", + "ENETDOWN", + "ENETRESET", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", + "MeiliSearchRequestError", + "MeiliSearchTimeOutError", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_SOCKET", +]); + +export type MeilisearchErrorContext = { + readonly provider?: "meilisearch"; + readonly operation: string; + readonly indexName?: string; + readonly documentId?: string; + readonly status?: number; + readonly upstreamCode?: string; + readonly retryable?: boolean; +}; + +type MeilisearchConfigKey = "host" | "apiKey" | "tenantTokenOptions.apiKeyUid"; + +/** + * 필수 Meilisearch 설정이 누락되었을 때 발생하는 Problem입니다. + */ +export class MissingMeilisearchConfigProblem extends Problem { + constructor(configKey: MeilisearchConfigKey, operation = "configuration") { + super( + "search-meilisearch/missing-config", + ProblemCategory.InternalServerError, + `Meilisearch configuration is missing required value '${configKey}'`, + { + extensions: { + provider: "meilisearch", + operation, + configKey, + retryable: false, + }, + }, + ); + } +} + +/** + * Meilisearch 요청 검증에 실패했을 때 발생하는 Problem입니다. + */ +export class MeilisearchInvalidRequestProblem extends Problem { + constructor(context: MeilisearchErrorContext, detail = "Meilisearch request validation failed") { + super( + "search-meilisearch/invalid-request", + ProblemCategory.ValidationError, + `${detail} during ${context.operation}`, + { + extensions: { + provider: "meilisearch", + ...context, + retryable: false, + }, + }, + ); + } +} + +/** + * 요청한 Meilisearch 인덱스를 찾을 수 없을 때 발생하는 Problem입니다. + */ +export class MeilisearchIndexNotFoundProblem extends Problem { + constructor(context: MeilisearchErrorContext) { + super( + "search-meilisearch/index-not-found", + ProblemCategory.NotFound, + `Meilisearch index was not found during ${context.operation}`, + { + extensions: { + provider: "meilisearch", + ...context, + retryable: false, + }, + }, + ); + } +} + +/** + * 재시도 가능한 Meilisearch 업스트림 실패를 나타내는 Problem입니다. + */ +export class MeilisearchRetryableUpstreamProblem extends Problem { + constructor(context: MeilisearchErrorContext, message: string) { + super( + "search-meilisearch/retryable-upstream", + ProblemCategory.InternalServerError, + `Meilisearch upstream request failed retryably during ${context.operation}: ${message}`, + { + extensions: { + provider: "meilisearch", + ...context, + retryable: true, + }, + }, + ); + } +} + +/** + * 재시도할 수 없는 Meilisearch 업스트림 실패를 나타내는 Problem입니다. + */ +export class MeilisearchTerminalUpstreamProblem extends Problem { + constructor(context: MeilisearchErrorContext, message: string) { + super( + "search-meilisearch/terminal-upstream", + ProblemCategory.InternalServerError, + `Meilisearch upstream request failed terminally during ${context.operation}: ${message}`, + { + extensions: { + provider: "meilisearch", + ...context, + retryable: false, + }, + }, + ); + } +} + /** * 테넌트 토큰 옵션 없이 토큰 발급을 시도할 때 발생하는 문제입니다. */ export class TenantTokenNotConfiguredProblem extends Problem { - readonly code = "search-meilisearch/tenant-token-not-configured"; - readonly category = ProblemCategory.InternalServerError; constructor() { - super(undefined, undefined, "Tenant token options are not configured"); + super( + "search-meilisearch/tenant-token-not-configured", + ProblemCategory.InternalServerError, + "Tenant token options are not configured", + { + extensions: { + provider: "meilisearch", + operation: "generateTenantToken", + retryable: false, + }, + }, + ); + } +} + +/** + * Meilisearch 오류를 표준화된 Problem 인스턴스로 변환합니다. + */ +export function normalizeMeilisearchError( + error: unknown, + context: MeilisearchErrorContext, +): Problem { + if (error instanceof Problem) { + return error; + } + + const normalizedContext = createMeilisearchErrorContext(error, context); + const safeMessage = redactSensitiveValue(getMeilisearchErrorMessage(error)); + + if (isIndexNotFoundError(normalizedContext, error)) { + return new MeilisearchIndexNotFoundProblem(normalizedContext); + } + + if (isValidationError(normalizedContext)) { + return new MeilisearchInvalidRequestProblem(normalizedContext, safeMessage); + } + + if (isRetryableMeilisearchError(error, normalizedContext)) { + return new MeilisearchRetryableUpstreamProblem(normalizedContext, safeMessage); + } + + return new MeilisearchTerminalUpstreamProblem(normalizedContext, safeMessage); +} + +/** + * Meilisearch 오류가 재시도 가능한지 여부를 판별합니다. + */ +export function isRetryableMeilisearchError( + error: unknown, + knownContext?: MeilisearchErrorContext, +): boolean { + const context = knownContext ?? createMeilisearchErrorContext(error, { operation: "unknown" }); + + if (context.status !== undefined && RETRYABLE_HTTP_STATUSES.has(context.status)) { + return true; + } + + if (context.upstreamCode && RETRYABLE_ERROR_CODES.has(context.upstreamCode)) { + return true; + } + + const message = getMeilisearchErrorMessage(error).toLowerCase(); + + return [ + "connection reset", + "connect timeout", + "econnreset", + "fetch failed", + "network error", + "rate limit", + "socket hang up", + "temporarily unavailable", + "timed out", + "timeout", + "too many requests", + "try again", + ].some((pattern) => message.includes(pattern)); +} + +function createMeilisearchErrorContext( + error: unknown, + context: MeilisearchErrorContext, +): MeilisearchErrorContext { + const record = asRecord(error); + const response = asRecord(record?.response); + const cause = asRecord(record?.cause); + const nestedError = asRecord(record?.error); + const status = firstNumber( + context.status, + record?.status, + record?.statusCode, + record?.httpStatus, + response?.status, + response?.statusCode, + cause?.status, + cause?.statusCode, + nestedError?.status, + nestedError?.statusCode, + ); + const upstreamCode = firstString( + context.upstreamCode, + record?.code, + record?.name, + cause?.code, + cause?.type, + cause?.name, + nestedError?.code, + nestedError?.type, + nestedError?.name, + ); + + return { + provider: "meilisearch", + operation: context.operation, + ...(context.indexName !== undefined && { indexName: context.indexName }), + ...(context.documentId !== undefined && { documentId: context.documentId }), + ...(status !== undefined && { status }), + ...(upstreamCode !== undefined && { upstreamCode }), + }; +} + +function isIndexNotFoundError(context: MeilisearchErrorContext, error: unknown): boolean { + const code = context.upstreamCode?.toLowerCase(); + + return ( + context.status === 404 || + code === "index_not_found" || + getMeilisearchErrorMessage(error).toLowerCase().includes("index not found") + ); +} + +function isValidationError(context: MeilisearchErrorContext): boolean { + const code = context.upstreamCode?.toLowerCase() ?? ""; + + if (context.status === 401 || context.status === 403) { + return false; + } + + return ( + context.status === 400 || + context.status === 422 || + (context.status === undefined && (code.startsWith("invalid_") || code.startsWith("missing_"))) + ); +} + +function getMeilisearchErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; } + + const record = asRecord(error); + const message = record?.message; + if (typeof message === "string" && message.length > 0) { + return message; + } + + const causeMessage = asRecord(record?.cause)?.message; + if (typeof causeMessage === "string" && causeMessage.length > 0) { + return causeMessage; + } + + const nestedMessage = asRecord(record?.error)?.message; + if (typeof nestedMessage === "string" && nestedMessage.length > 0) { + return nestedMessage; + } + + return "unknown upstream error"; +} + +function firstNumber(...values: readonly unknown[]): number | undefined { + for (const value of values) { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isInteger(parsed)) { + return parsed; + } + } + } + + return undefined; +} + +function firstString(...values: readonly unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + + return undefined; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +const SENSITIVE_KEY_PATTERN = + "credential|password|secret|token|api[-_]?key|private[-_]?key|access[-_]?key|connection[-_]?string|dsn"; + +function redactSensitiveValue(value: string): string { + return value + .replace(/\b(authorization)(\s*[:=]\s*)[^,\n;]+/gi, "$1$2[redacted]") + .replace(/\b(cookie)(\s*[:=]\s*)[^,\n]+/gi, "$1$2[redacted]") + .replace( + new RegExp( + `(["']?)(${SENSITIVE_KEY_PATTERN})\\1(\\s*[:=]\\s*)(["']?)([^"',\\s;&}]+)\\4`, + "gi", + ), + "$1$2$1$3$4[redacted]$4", + ) + .replace(new RegExp(`([?&](${SENSITIVE_KEY_PATTERN})=)[^&#\\s]+`, "gi"), "$1[redacted]"); } diff --git a/packages/search-meilisearch/src/libs/types.ts b/packages/search-meilisearch/src/libs/types.ts index d2aeb0f7e..0532bd37d 100644 --- a/packages/search-meilisearch/src/libs/types.ts +++ b/packages/search-meilisearch/src/libs/types.ts @@ -6,6 +6,15 @@ export type TenantTokenOptions = { expiresIn?: number; // seconds }; +/** + * Meilisearch 비동기 task 완료 대기 옵션입니다. + */ +export type MeilisearchTaskWaitOptions = { + enabled?: boolean; + timeoutMs?: number; + intervalMs?: number; +}; + /** * Meilisearch 엔진 초기화 옵션입니다. */ @@ -13,4 +22,5 @@ export type MeilisearchEngineOptions = { host: string; apiKey: string; tenantTokenOptions?: TenantTokenOptions; + taskWait?: MeilisearchTaskWaitOptions; }; diff --git a/packages/search-meilisearch/src/tests/MeilisearchDiagnosticsProvider.spec.ts b/packages/search-meilisearch/src/tests/MeilisearchDiagnosticsProvider.spec.ts new file mode 100644 index 000000000..4c478f3d1 --- /dev/null +++ b/packages/search-meilisearch/src/tests/MeilisearchDiagnosticsProvider.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; +import { MeilisearchDiagnosticsProvider } from "../libs/MeilisearchDiagnosticsProvider"; +import { TenantTokenNotConfiguredProblem } from "../libs/problems/MeilisearchProblems"; + +const SECRET_SAMPLE = "super-secret-token"; + +const mocks = vi.hoisted(() => { + const client = { + health: vi.fn(), + }; + const constructor = vi.fn(); + return { clientMock: client, constructorMock: constructor }; +}); + +vi.mock("meilisearch", () => ({ + MeiliSearch: class { + constructor(options: unknown) { + mocks.constructorMock(options); + Object.assign(this, mocks.clientMock); + } + }, +})); + +describe("MeilisearchDiagnosticsProvider", () => { + it("reports missing config without leaking raw values", async () => { + const diagnostics = new MeilisearchDiagnosticsProvider({ + apiKey: SECRET_SAMPLE, + host: "", + }); + + const health = await diagnostics.getHealth(); + const serialized = JSON.stringify(health); + + expect(health).toMatchObject({ + component: "search-meilisearch", + details: expect.objectContaining({ + hasApiKey: true, + hasHost: false, + liveCheck: "not_started", + problemCode: "search-meilisearch/missing-config", + }), + status: "unhealthy", + }); + expect(serialized).not.toContain(SECRET_SAMPLE); + }); + + it("reports healthy config when live readiness is not configured", async () => { + const diagnostics = new MeilisearchDiagnosticsProvider({ + apiKey: SECRET_SAMPLE, + host: "http://localhost:7700", + }); + + const health = await diagnostics.getHealth(); + + expect(health).toMatchObject({ + component: "search-meilisearch", + details: expect.objectContaining({ + hasApiKey: true, + hasHost: true, + liveCheck: "not_configured", + }), + status: "healthy", + }); + }); + + it("runs readiness checks and redacts returned diagnostic details", async () => { + const diagnostics = new MeilisearchDiagnosticsProvider( + { + apiKey: SECRET_SAMPLE, + host: "http://localhost:7700", + }, + { + readinessCheck: async ({ client }) => { + expect(client.health).toBe(mocks.clientMock.health); + return { + message: `Authorization: Bearer ${SECRET_SAMPLE}; access_token=${SECRET_SAMPLE}; Cookie: session=${SECRET_SAMPLE}`, + details: { + accessKey: SECRET_SAMPLE, + apiKey: SECRET_SAMPLE, + cookie: SECRET_SAMPLE, + endpoint: `https://search.example?apiKey=${SECRET_SAMPLE}`, + nested: { + access_token: SECRET_SAMPLE, + token: SECRET_SAMPLE, + }, + reachable: true, + }, + }; + }, + }, + ); + + const health = await diagnostics.getHealth(); + const serialized = JSON.stringify(health); + + expect(health).toMatchObject({ + component: "search-meilisearch", + message: "Authorization: [redacted]; access_token=[redacted]; Cookie: [redacted]", + details: expect.objectContaining({ + liveCheck: "passed", + readiness: { + accessKey: "[redacted]", + apiKey: "[redacted]", + cookie: "[redacted]", + endpoint: "https://search.example?apiKey=[redacted]", + nested: { + access_token: "[redacted]", + token: "[redacted]", + }, + reachable: true, + }, + }), + status: "healthy", + }); + expect(serialized).not.toContain(SECRET_SAMPLE); + }); + + it("redacts sensitive extensions from readiness Problems before returning health details", async () => { + const diagnostics = new MeilisearchDiagnosticsProvider( + { + apiKey: SECRET_SAMPLE, + host: "http://localhost:7700", + }, + { + readinessCheck: async () => { + const problem = new TenantTokenNotConfiguredProblem(); + Object.assign(problem.extensions ?? {}, { + accessKey: SECRET_SAMPLE, + apiKey: SECRET_SAMPLE, + cookie: SECRET_SAMPLE, + endpoint: `https://search.example?apiKey=${SECRET_SAMPLE}`, + nested: { + access_token: SECRET_SAMPLE, + token: SECRET_SAMPLE, + }, + }); + throw problem; + }, + }, + ); + + const health = await diagnostics.getHealth(); + const serialized = JSON.stringify(health); + + expect(health).toMatchObject({ + component: "search-meilisearch", + details: expect.objectContaining({ + accessKey: "[redacted]", + apiKey: "[redacted]", + cookie: "[redacted]", + endpoint: "https://search.example?apiKey=[redacted]", + liveCheck: "failed", + nested: { + access_token: "[redacted]", + token: "[redacted]", + }, + problemCode: "search-meilisearch/tenant-token-not-configured", + }), + status: "degraded", + }); + expect(serialized).not.toContain(SECRET_SAMPLE); + }); + + it("normalizes readiness failures as redacted upstream Problems", async () => { + const diagnostics = new MeilisearchDiagnosticsProvider( + { + apiKey: SECRET_SAMPLE, + host: "http://localhost:7700", + }, + { + readinessCheck: async () => { + const error = new Error(`Authorization: Bearer ${SECRET_SAMPLE}`); + Object.assign(error, { response: { status: 503 } }); + throw error; + }, + }, + ); + + const health = await diagnostics.getHealth(); + const serialized = JSON.stringify(health); + + expect(health).toMatchObject({ + component: "search-meilisearch", + details: expect.objectContaining({ + liveCheck: "failed", + problemCode: "search-meilisearch/retryable-upstream", + retryable: true, + status: 503, + }), + status: "degraded", + }); + expect(serialized).not.toContain(SECRET_SAMPLE); + }); +}); diff --git a/packages/search-meilisearch/src/tests/MeilisearchEngine.spec.ts b/packages/search-meilisearch/src/tests/MeilisearchEngine.spec.ts index ac1e673c5..6ab06c497 100644 --- a/packages/search-meilisearch/src/tests/MeilisearchEngine.spec.ts +++ b/packages/search-meilisearch/src/tests/MeilisearchEngine.spec.ts @@ -1,36 +1,96 @@ import { Context } from "@croco/framework-context"; -import { MissingTenantProblem } from "@croco/search-core"; +import { MissingTenantProblem, SearchService } from "@croco/search-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MeilisearchEngine } from "../libs/MeilisearchEngine"; +import { + MeilisearchIndexNotFoundProblem, + MeilisearchInvalidRequestProblem, + MeilisearchRetryableUpstreamProblem, + MeilisearchTerminalUpstreamProblem, + MissingMeilisearchConfigProblem, +} from "../libs/problems/MeilisearchProblems"; import type { MeilisearchEngineOptions } from "../libs/types"; +const SECRET_SAMPLE = "super-secret-token"; +const SECRET_RICH_ERROR_MESSAGE = `Authorization: Bearer ${SECRET_SAMPLE}; token=${SECRET_SAMPLE}; https://search.example?apiKey=${SECRET_SAMPLE}; Cookie: session=${SECRET_SAMPLE}`; +type ProblemConstructor = Function & { + readonly prototype: TProblem; + readonly name: string; +}; + const mocks = vi.hoisted(() => { const index = { search: vi.fn(), addDocuments: vi.fn(), - deleteDocument: vi.fn(), deleteDocuments: vi.fn(), updateSettings: vi.fn(), - delete: vi.fn(), }; const client = { index: vi.fn(() => index), createIndex: vi.fn(), deleteIndex: vi.fn(), generateTenantToken: vi.fn(), + waitForTask: vi.fn(), + health: vi.fn(), }; - return { clientMock: client, indexMock: index }; + const constructor = vi.fn(); + return { clientMock: client, constructorMock: constructor, indexMock: index }; }); vi.mock("meilisearch", () => ({ MeiliSearch: class { - constructor() { + constructor(options: unknown) { + mocks.constructorMock(options); Object.assign(this, mocks.clientMock); } }, })); -describe("MeilisearchEngine", () => { +function createUpstreamError( + message: string, + options: { + readonly status?: number; + readonly code?: string; + readonly name?: string; + }, +): Error & { + response?: { readonly status: number }; + cause?: { readonly code?: string; readonly message?: string; readonly type?: string }; + code?: string; +} { + const error = new Error(message) as Error & { + response?: { status: number }; + cause?: { code?: string; message?: string; type?: string }; + code?: string; + }; + if (options.name) { + error.name = options.name; + } + if (options.status !== undefined) { + error.response = { status: options.status }; + } + if (options.code !== undefined) { + error.code = options.code; + error.cause = { code: options.code, message }; + } + return error; +} + +async function expectProblem( + action: () => Promise, + problemClass: ProblemConstructor, +): Promise { + try { + await action(); + } catch (error) { + expect(error).toBeInstanceOf(problemClass); + return error as TProblem; + } + + throw new Error(`Expected ${problemClass.name} to be thrown.`); +} + +describe("Meilisearch provider conformance", () => { let engine!: MeilisearchEngine; const options: MeilisearchEngineOptions = { @@ -44,52 +104,122 @@ describe("MeilisearchEngine", () => { beforeEach(() => { vi.clearAllMocks(); - // Setup default returns - mocks.indexMock.search.mockResolvedValue({ hits: [], estimatedTotalHits: 0 }); + mocks.indexMock.search.mockResolvedValue({ + estimatedTotalHits: 0, + hits: [], + processingTimeMs: 0, + }); mocks.indexMock.addDocuments.mockResolvedValue({ taskUid: 1 }); - mocks.indexMock.deleteDocument.mockResolvedValue({ taskUid: 1 }); - mocks.indexMock.deleteDocuments.mockResolvedValue({ taskUid: 1 }); - mocks.indexMock.updateSettings.mockResolvedValue({ taskUid: 1 }); + mocks.indexMock.deleteDocuments.mockResolvedValue({ taskUid: 2 }); + mocks.indexMock.updateSettings.mockResolvedValue({ taskUid: 3 }); - mocks.clientMock.createIndex.mockResolvedValue({ taskUid: 1 }); - mocks.clientMock.deleteIndex.mockResolvedValue({ taskUid: 1 }); - mocks.clientMock.generateTenantToken.mockReturnValue("token"); + mocks.clientMock.createIndex.mockResolvedValue({ taskUid: 4 }); + mocks.clientMock.deleteIndex.mockResolvedValue({ taskUid: 5 }); + mocks.clientMock.generateTenantToken.mockResolvedValue("token"); + mocks.clientMock.waitForTask.mockResolvedValue({ status: "succeeded" }); engine = new MeilisearchEngine(options); }); + describe("configuration", () => { + it("fails missing host and API key with stable Problems", () => { + expect(() => new MeilisearchEngine({ host: "", apiKey: "masterKey" })).toThrow( + MissingMeilisearchConfigProblem, + ); + expect(() => new MeilisearchEngine({ host: "http://localhost:7700", apiKey: "" })).toThrow( + MissingMeilisearchConfigProblem, + ); + }); + + it("constructs the Meilisearch client without exposing configuration in diagnostics", () => { + expect(mocks.constructorMock).toHaveBeenLastCalledWith({ + apiKey: "masterKey", + host: "http://localhost:7700", + }); + }); + }); + describe("search", () => { - it("should throw MissingTenantProblem if tenantId is missing", async () => { + it("throws MissingTenantProblem if tenantId is missing", async () => { vi.spyOn(Context, "getTenantId").mockReturnValue(null); await expect(engine.search("index", { query: "test" })).rejects.toThrow(MissingTenantProblem); }); - it("should add tenant filter to search query", async () => { + it("adds tenant filter, query controls, and supported scalar filters", async () => { vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); - await engine.search("index", { query: "test" }); + mocks.indexMock.search.mockResolvedValue({ + estimatedTotalHits: 1, + hits: [{ id: "1", title: "Croco" }], + processingTimeMs: 7, + }); + + const result = await engine.search<{ id: string; title: string }>("index", { + query: "croco", + filters: { active: true, price: 10, status: "published" }, + limit: 5, + offset: 10, + sort: [{ field: "price", order: "desc" }], + }); expect(mocks.clientMock.index).toHaveBeenCalledWith("index"); expect(mocks.indexMock.search).toHaveBeenCalledWith( - "test", + "croco", expect.objectContaining({ - filter: expect.arrayContaining(['_tenantId = "tenant-1"']), + filter: expect.arrayContaining([ + '_tenantId = "tenant-1"', + "active = true", + "price = 10", + 'status = "published"', + ]), + limit: 5, + offset: 10, + sort: ["price:desc"], }), ); + expect(result).toMatchObject({ + processingTimeMs: 7, + total: 1, + hits: [{ document: { id: "1", title: "Croco" } }], + }); }); - it("should combine with existing filters", async () => { + it("accepts SearchService tenant filters by mapping them to provider tenant isolation", async () => { vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); - await engine.search("index", { query: "test", filters: { status: "active" } }); + const searchService = new SearchService({ engine }); - expect(mocks.indexMock.search).toHaveBeenCalledWith( - "test", - expect.objectContaining({ - filter: expect.arrayContaining(['_tenantId = "tenant-1"', 'status = "active"']), + await searchService.search("index", { + query: "croco", + filters: { status: "published" }, + }); + + const searchOptions = mocks.indexMock.search.mock.calls.at(-1)?.[1] as { + readonly filter: readonly string[]; + }; + expect(searchOptions.filter).toContain('_tenantId = "tenant-1"'); + expect(searchOptions.filter).toContain('status = "published"'); + expect(searchOptions.filter).not.toContain('tenantId = "tenant-1"'); + }); + + it("rejects tenant filters that conflict with the active tenant context", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + + await expect( + engine.search("index", { + query: "test", + filters: { tenantId: "tenant-2" }, }), - ); + ).rejects.toThrow(MeilisearchInvalidRequestProblem); + await expect( + engine.search("index", { + query: "test", + filters: { _tenantId: "tenant-2" }, + }), + ).rejects.toThrow(MeilisearchInvalidRequestProblem); + + expect(mocks.indexMock.search).not.toHaveBeenCalled(); }); - it("should escape quotes and backslashes in string filters and tenant filters", async () => { + it("escapes quotes and backslashes in string filters and tenant filters", async () => { vi.spyOn(Context, "getTenantId").mockReturnValue('tenant-"one"\\x'); await engine.search("index", { @@ -109,56 +239,136 @@ describe("MeilisearchEngine", () => { }), ); }); - }); - describe("indexDocument", () => { - it("should throw MissingTenantProblem if tenantId is missing", async () => { - vi.spyOn(Context, "getTenantId").mockReturnValue(null); + it("rejects unsafe filter and sort fields before sending the search request", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + + await expect( + engine.search("index", { + query: "test", + filters: { "status OR _tenantId": "active" }, + }), + ).rejects.toThrow(MeilisearchInvalidRequestProblem); + await expect( - engine.indexDocument("index", { id: "1", tenantId: "tenant-1" }), - ).rejects.toThrow(MissingTenantProblem); + engine.search("index", { + query: "test", + sort: [{ field: "title;DROP", order: "asc" }], + }), + ).rejects.toThrow(MeilisearchInvalidRequestProblem); + + expect(mocks.indexMock.search).not.toHaveBeenCalled(); }); + }); - it("should add _tenantId field to document", async () => { - vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); - await engine.indexDocument("index", { id: "1", tenantId: "tenant-1", title: "test" }); + describe("index lifecycle", () => { + it("creates indexes with tenant filterability and configured searchable/sortable fields", async () => { + await engine.createIndex({ + name: "products", + filterableFields: ["category"], + searchableFields: ["title"], + sortableFields: ["price"], + }); - expect(mocks.indexMock.addDocuments).toHaveBeenCalledWith([ - expect.objectContaining({ id: "1", title: "test", _tenantId: "tenant-1" }), - ]); + expect(mocks.clientMock.createIndex).toHaveBeenCalledWith("products", { primaryKey: "id" }); + expect(mocks.indexMock.updateSettings).toHaveBeenCalledWith({ + filterableAttributes: ["_tenantId", "id", "category"], + searchableAttributes: ["title"], + sortableAttributes: ["price"], + }); + expect(mocks.clientMock.waitForTask).toHaveBeenCalledWith(4, {}); + expect(mocks.clientMock.waitForTask).toHaveBeenCalledWith(3, {}); + }); + + it("keeps Meilisearch default searchable attributes when searchable fields are omitted", async () => { + await engine.createIndex({ name: "products" }); + + expect(mocks.indexMock.updateSettings).toHaveBeenCalledWith({ + filterableAttributes: ["_tenantId", "id"], + sortableAttributes: [], + }); + expect(mocks.indexMock.updateSettings).not.toHaveBeenCalledWith( + expect.objectContaining({ searchableAttributes: [] }), + ); + }); + + it("deletes indexes and waits for the provider task", async () => { + await engine.deleteIndex("products"); + + expect(mocks.clientMock.deleteIndex).toHaveBeenCalledWith("products"); + expect(mocks.clientMock.waitForTask).toHaveBeenCalledWith(5, {}); }); }); - describe("createIndex", () => { - it("should create index and update settings with _tenantId filterable", async () => { - await engine.createIndex({ name: "new-index", filterableFields: ["category"] }); + describe("document writes", () => { + it("adds the active tenant to indexed documents and waits for the task", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); - expect(mocks.clientMock.createIndex).toHaveBeenCalledWith("new-index", { primaryKey: "id" }); - expect(mocks.indexMock.updateSettings).toHaveBeenCalledWith( + await engine.indexDocument("products", { + _tenantId: "forged-tenant", + id: "1", + tenantId: "tenant-from-document", + title: "test", + }); + + expect(mocks.indexMock.addDocuments).toHaveBeenCalledWith([ expect.objectContaining({ - filterableAttributes: expect.arrayContaining(["_tenantId", "category"]), + _tenantId: "tenant-1", + id: "1", + tenantId: "tenant-1", + title: "test", }), + ]); + expect(mocks.clientMock.waitForTask).toHaveBeenCalledWith(1, {}); + }); + + it("bulk indexes documents and preserves tenant isolation", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + + await engine.bulkIndex("products", [ + { id: "1", tenantId: "ignored", title: "One" }, + { id: "2", tenantId: "ignored", title: "Two" }, + ]); + + expect(mocks.indexMock.addDocuments).toHaveBeenCalledWith([ + expect.objectContaining({ _tenantId: "tenant-1", id: "1", tenantId: "tenant-1" }), + expect.objectContaining({ _tenantId: "tenant-1", id: "2", tenantId: "tenant-1" }), + ]); + }); + + it("treats empty bulk index input as a deterministic no-op", async () => { + await engine.bulkIndex("products", []); + + expect(mocks.indexMock.addDocuments).not.toHaveBeenCalled(); + expect(mocks.clientMock.waitForTask).not.toHaveBeenCalled(); + }); + + it("rejects invalid index and document identifiers", async () => { + await expect(engine.indexDocument("", { id: "1", tenantId: "tenant-1" })).rejects.toThrow( + MeilisearchInvalidRequestProblem, ); + await expect( + engine.indexDocument("products", { id: "", tenantId: "tenant-1" }), + ).rejects.toThrow(MeilisearchInvalidRequestProblem); }); }); - describe("generateTenantToken", () => { - it("should generate tenant token using SDK", async () => { - const token = await engine.generateTenantToken("tenant-1"); - expect(mocks.clientMock.generateTenantToken).toHaveBeenCalledWith( - options.tenantTokenOptions?.apiKeyUid, - { - "*": { - filter: `_tenantId = "tenant-1"`, - }, - }, - expect.anything(), - ); - expect(token).toBe("token"); + describe("deleteDocument", () => { + it("deletes by tenant-bound filter and escapes tenant and document ids", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue('tenant-"one"\\x'); + + await engine.deleteDocument("index", 'doc-"id"\\x'); + + expect(mocks.indexMock.deleteDocuments).toHaveBeenCalledWith({ + filter: '_tenantId = "tenant-\\"one\\"\\\\x" AND id = "doc-\\"id\\"\\\\x"', + }); + expect(mocks.clientMock.waitForTask).toHaveBeenCalledWith(2, {}); }); + }); - it("should escape tenant ids in tenant token filters", async () => { - await engine.generateTenantToken('tenant-"one"\\x'); + describe("generateTenantToken", () => { + it("generates tenant token using escaped tenant search rules", async () => { + const token = await engine.generateTenantToken('tenant-"one"\\x'); expect(mocks.clientMock.generateTenantToken).toHaveBeenCalledWith( options.tenantTokenOptions?.apiKeyUid, @@ -169,9 +379,10 @@ describe("MeilisearchEngine", () => { }, expect.anything(), ); + expect(token).toBe("token"); }); - it("should compute expiresAt when expiresIn is 0", async () => { + it("computes expiresAt when expiresIn is 0", async () => { const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); const engineWithZeroExpiresIn = new MeilisearchEngine({ @@ -188,7 +399,7 @@ describe("MeilisearchEngine", () => { "uid", { "*": { - filter: `_tenantId = "tenant-1"`, + filter: '_tenantId = "tenant-1"', }, }, { @@ -198,17 +409,99 @@ describe("MeilisearchEngine", () => { dateNowSpy.mockRestore(); }); + + it("rejects missing token configuration and empty tenant ids", async () => { + const engineWithoutTokenConfig = new MeilisearchEngine({ + apiKey: "masterKey", + host: "http://localhost:7700", + }); + + await expect(engineWithoutTokenConfig.generateTenantToken("tenant-1")).rejects.toMatchObject({ + code: "search-meilisearch/tenant-token-not-configured", + }); + await expect(engine.generateTenantToken("")).rejects.toThrow( + MeilisearchInvalidRequestProblem, + ); + }); }); - describe("deleteDocument", () => { - it("should escape tenant and document ids in delete filters", async () => { - vi.spyOn(Context, "getTenantId").mockReturnValue('tenant-"one"\\x'); + describe("upstream failure normalization", () => { + it("normalizes retryable upstream failures and redacts sensitive error details", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + mocks.indexMock.search.mockRejectedValue( + createUpstreamError(SECRET_RICH_ERROR_MESSAGE, { + code: "temporarily_unavailable", + status: 503, + }), + ); - await engine.deleteDocument("index", 'doc-"id"\\x'); + const problem = await expectProblem( + () => engine.search("products", { query: "croco" }), + MeilisearchRetryableUpstreamProblem, + ); - expect(mocks.indexMock.deleteDocuments).toHaveBeenCalledWith({ - filter: '_tenantId = "tenant-\\"one\\"\\\\x" AND id = "doc-\\"id\\"\\\\x"', + expect(problem.detail).toContain("search"); + expect(problem.detail).not.toContain(SECRET_SAMPLE); + expect(problem.extensions).toMatchObject({ + operation: "search", + retryable: true, + status: 503, + }); + }); + + it("normalizes terminal upstream failures", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + mocks.indexMock.search.mockRejectedValue( + createUpstreamError("invalid key", { code: "invalid_api_key", status: 401 }), + ); + + const problem = await expectProblem( + () => engine.search("products", { query: "croco" }), + MeilisearchTerminalUpstreamProblem, + ); + + expect(problem.extensions).toMatchObject({ + operation: "search", + retryable: false, + status: 401, + }); + }); + + it("normalizes index-not-found upstream failures", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + mocks.indexMock.search.mockRejectedValue( + createUpstreamError("index not found", { code: "index_not_found", status: 404 }), + ); + + await expectProblem( + () => engine.search("products", { query: "croco" }), + MeilisearchIndexNotFoundProblem, + ); + }); + + it("normalizes failed async tasks through the same Problem taxonomy", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + mocks.clientMock.waitForTask.mockResolvedValueOnce({ + error: { code: "index_not_found", message: "index not found" }, + status: "failed", }); + + await expectProblem( + () => engine.indexDocument("products", { id: "1", tenantId: "tenant-1" }), + MeilisearchIndexNotFoundProblem, + ); + }); + + it("fails malformed async task responses while task waiting is enabled", async () => { + vi.spyOn(Context, "getTenantId").mockReturnValue("tenant-1"); + mocks.indexMock.addDocuments.mockResolvedValueOnce({}); + + await expectProblem( + () => engine.indexDocument("products", { id: "1", tenantId: "tenant-1" }), + MeilisearchInvalidRequestProblem, + ); + + expect(mocks.clientMock.waitForTask).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/search-meilisearch/src/tests/MeilisearchLiveSmoke.spec.ts b/packages/search-meilisearch/src/tests/MeilisearchLiveSmoke.spec.ts new file mode 100644 index 000000000..8563447ee --- /dev/null +++ b/packages/search-meilisearch/src/tests/MeilisearchLiveSmoke.spec.ts @@ -0,0 +1,77 @@ +import { Context } from "@croco/framework-context"; +import type { SearchHit } from "@croco/search-core"; +import { describe, expect, it, vi } from "vitest"; +import { MeilisearchDiagnosticsProvider } from "../libs/MeilisearchDiagnosticsProvider"; +import { MeilisearchEngine } from "../libs/MeilisearchEngine"; +import type { MeilisearchEngineOptions } from "../libs/types"; + +const MEILISEARCH_LIVE_ENV = ["MEILISEARCH_HOST", "MEILISEARCH_API_KEY"] as const; + +const missingLiveSmokeEnv = MEILISEARCH_LIVE_ENV.filter((name) => !process.env[name]); + +const liveConfig: MeilisearchEngineOptions = { + apiKey: process.env.MEILISEARCH_API_KEY ?? "", + host: process.env.MEILISEARCH_HOST ?? "", + taskWait: { + timeoutMs: 10_000, + }, +}; + +describe("Meilisearch live smoke", () => { + it.skipIf(missingLiveSmokeEnv.length > 0)( + "requires MEILISEARCH_HOST and MEILISEARCH_API_KEY for live Meilisearch readiness and search smoke", + async () => { + const diagnostics = new MeilisearchDiagnosticsProvider(liveConfig, { + readinessCheck: async ({ client }) => { + await client.health(); + return { + details: { + reachable: true, + }, + }; + }, + }); + const health = await diagnostics.getHealth(); + expect(health).toMatchObject({ + component: "search-meilisearch", + details: expect.objectContaining({ + liveCheck: "passed", + }), + status: "healthy", + }); + + const tenantId = `tenant-${Date.now()}`; + const indexName = `croco_live_smoke_${Date.now()}`; + vi.spyOn(Context, "getTenantId").mockReturnValue(tenantId); + + const engine = new MeilisearchEngine(liveConfig); + + try { + await engine.createIndex({ + filterableFields: ["kind"], + name: indexName, + searchableFields: ["title"], + }); + await engine.indexDocument(indexName, { + id: "doc-1", + kind: "smoke", + tenantId, + title: "Croco Meilisearch live smoke", + }); + + const result = await engine.search<{ id: string; title: string }>(indexName, { + filters: { kind: "smoke" }, + query: "Croco", + }); + + expect( + result.hits.some( + (hit: SearchHit<{ id: string; title: string }>) => hit.document.id === "doc-1", + ), + ).toBe(true); + } finally { + await engine.deleteIndex(indexName).catch(() => undefined); + } + }, + ); +}); diff --git a/packages/search-meilisearch/src/tests/MeilisearchProblems.spec.ts b/packages/search-meilisearch/src/tests/MeilisearchProblems.spec.ts index 77a8649ed..01038c7a5 100644 --- a/packages/search-meilisearch/src/tests/MeilisearchProblems.spec.ts +++ b/packages/search-meilisearch/src/tests/MeilisearchProblems.spec.ts @@ -1,6 +1,17 @@ import { ProblemCategory } from "@croco/problems-core"; import { describe, expect, it } from "vitest"; -import { TenantTokenNotConfiguredProblem } from "../libs/problems/MeilisearchProblems"; +import { + isRetryableMeilisearchError, + MeilisearchIndexNotFoundProblem, + MeilisearchInvalidRequestProblem, + MeilisearchRetryableUpstreamProblem, + MeilisearchTerminalUpstreamProblem, + MissingMeilisearchConfigProblem, + normalizeMeilisearchError, + TenantTokenNotConfiguredProblem, +} from "../libs/problems/MeilisearchProblems"; + +const SECRET_SAMPLE = "super-secret-token"; describe("MeilisearchProblems", () => { it("TenantTokenNotConfiguredProblem has correct code and category", () => { @@ -9,5 +20,89 @@ describe("MeilisearchProblems", () => { expect(problem.code).toBe("search-meilisearch/tenant-token-not-configured"); expect(problem.category).toBe(ProblemCategory.InternalServerError); expect(problem.detail).toBe("Tenant token options are not configured"); + expect(problem.extensions).toMatchObject({ + operation: "generateTenantToken", + retryable: false, + }); + }); + + it("MissingMeilisearchConfigProblem identifies the missing key", () => { + const problem = new MissingMeilisearchConfigProblem("apiKey"); + + expect(problem.code).toBe("search-meilisearch/missing-config"); + expect(problem.category).toBe(ProblemCategory.InternalServerError); + expect(problem.extensions).toMatchObject({ + configKey: "apiKey", + retryable: false, + }); + }); + + it("MeilisearchInvalidRequestProblem is a validation Problem", () => { + const problem = new MeilisearchInvalidRequestProblem({ operation: "search" }, "Invalid filter"); + + expect(problem.code).toBe("search-meilisearch/invalid-request"); + expect(problem.category).toBe(ProblemCategory.ValidationError); + expect(problem.extensions).toMatchObject({ + operation: "search", + retryable: false, + }); + }); + + it("MeilisearchIndexNotFoundProblem is not retryable", () => { + const problem = new MeilisearchIndexNotFoundProblem({ + indexName: "products", + operation: "search", + }); + + expect(problem.code).toBe("search-meilisearch/index-not-found"); + expect(problem.category).toBe(ProblemCategory.NotFound); + expect(problem.extensions).toMatchObject({ + indexName: "products", + retryable: false, + }); + }); + + it("normalizes retryable and terminal upstream failures", () => { + const retryable = normalizeMeilisearchError( + Object.assign(new Error(`token=${SECRET_SAMPLE}`), { + response: { status: 503 }, + }), + { operation: "search" }, + ); + const terminal = normalizeMeilisearchError( + Object.assign(new Error("bad credentials"), { + response: { status: 401 }, + }), + { operation: "search" }, + ); + + expect(retryable).toBeInstanceOf(MeilisearchRetryableUpstreamProblem); + expect(retryable.detail).not.toContain(SECRET_SAMPLE); + expect(retryable.extensions).toMatchObject({ + retryable: true, + status: 503, + }); + expect(terminal).toBeInstanceOf(MeilisearchTerminalUpstreamProblem); + expect(terminal.extensions).toMatchObject({ + retryable: false, + status: 401, + }); + }); + + it("detects retryable Meilisearch request and timeout errors", () => { + expect( + isRetryableMeilisearchError( + Object.assign(new Error("unexpected failure"), { + name: "MeiliSearchTimeOutError", + }), + ), + ).toBe(true); + expect( + isRetryableMeilisearchError( + Object.assign(new Error("unexpected failure"), { + name: "MeiliSearchRequestError", + }), + ), + ).toBe(true); }); }); diff --git a/packages/search-meilisearch/tsconfig.json b/packages/search-meilisearch/tsconfig.json index 5643799f9..562a67674 100644 --- a/packages/search-meilisearch/tsconfig.json +++ b/packages/search-meilisearch/tsconfig.json @@ -1,10 +1,16 @@ { "extends": "../../tsconfig/tsconfig.node.json", "compilerOptions": { + "baseUrl": ".", "outDir": "dist", - "rootDir": "src", "moduleResolution": "bundler", - "module": "esnext" + "module": "esnext", + "paths": { + "@croco/diagnostics-core": ["../diagnostics-core/src/index.ts"], + "@croco/framework-context": ["../framework-context/src/index.ts"], + "@croco/problems-core": ["../problems-core/src/index.ts"], + "@croco/search-core": ["../search-core/src/index.ts"] + } }, "include": ["src"] } diff --git a/packages/search-meilisearch/vitest.config.ts b/packages/search-meilisearch/vitest.config.ts index 3f824fb95..8bea212b0 100644 --- a/packages/search-meilisearch/vitest.config.ts +++ b/packages/search-meilisearch/vitest.config.ts @@ -1,6 +1,18 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const currentDir = dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + resolve: { + alias: { + "@croco/diagnostics-core": resolve(currentDir, "../diagnostics-core/src/index.ts"), + "@croco/framework-context": resolve(currentDir, "../framework-context/src/index.ts"), + "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"), + "@croco/search-core": resolve(currentDir, "../search-core/src/index.ts"), + }, + }, test: { globals: true, environment: "node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6857f60dd..c3f2c6ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2711,6 +2711,9 @@ importers: packages/search-meilisearch: dependencies: + '@croco/diagnostics-core': + specifier: workspace:* + version: link:../diagnostics-core '@croco/framework-context': specifier: workspace:* version: link:../framework-context diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 6e5aba93b..67e6a5253 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -15591,12 +15591,60 @@ "relativeDir": "packages/search-meilisearch", "entrypoint": "packages/search-meilisearch/src/index.ts", "runtimeExports": [ + { + "name": "MeilisearchDiagnosticsProvider", + "exportKind": "declaration", + "source": "./libs/MeilisearchDiagnosticsProvider", + "declarationKind": "class" + }, { "name": "MeilisearchEngine", "exportKind": "declaration", "source": "./libs/MeilisearchEngine", "declarationKind": "class" }, + { + "name": "isRetryableMeilisearchError", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "function" + }, + { + "name": "MeilisearchIndexNotFoundProblem", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "class" + }, + { + "name": "MeilisearchInvalidRequestProblem", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "class" + }, + { + "name": "MeilisearchRetryableUpstreamProblem", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "class" + }, + { + "name": "MeilisearchTerminalUpstreamProblem", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "class" + }, + { + "name": "MissingMeilisearchConfigProblem", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "class" + }, + { + "name": "normalizeMeilisearchError", + "exportKind": "named", + "source": "./libs/problems/MeilisearchProblems", + "declarationKind": "function" + }, { "name": "TenantTokenNotConfiguredProblem", "exportKind": "named", @@ -15605,12 +15653,36 @@ } ], "typeExports": [ + { + "name": "MeilisearchDiagnosticsOptions", + "exportKind": "declaration", + "source": "./libs/MeilisearchDiagnosticsProvider", + "declarationKind": "type" + }, { "name": "MeilisearchEngineOptions", "exportKind": "declaration", "source": "./libs/types", "declarationKind": "type" }, + { + "name": "MeilisearchReadinessCheckContext", + "exportKind": "declaration", + "source": "./libs/MeilisearchDiagnosticsProvider", + "declarationKind": "type" + }, + { + "name": "MeilisearchReadinessCheckResult", + "exportKind": "declaration", + "source": "./libs/MeilisearchDiagnosticsProvider", + "declarationKind": "type" + }, + { + "name": "MeilisearchTaskWaitOptions", + "exportKind": "declaration", + "source": "./libs/types", + "declarationKind": "type" + }, { "name": "TenantTokenOptions", "exportKind": "declaration",