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",