@jini-ai/agent-plugins 0.6.17 → 0.6.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/NOTICE +14 -0
- package/README.md +35 -14
- package/charts-echarts/plugin.json +39 -0
- package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
- package/charts-recharts/plugin.json +32 -0
- package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
- package/cloudflare/NOTES.md +16 -0
- package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
- package/cloudflare/lib/api.mjs +96 -0
- package/cloudflare/lib/assets.mjs +65 -0
- package/cloudflare/lib/dns.mjs +32 -0
- package/cloudflare/lib/domain-inventory.mjs +61 -0
- package/cloudflare/lib/domain-status.mjs +48 -0
- package/cloudflare/lib/domains.mjs +95 -0
- package/cloudflare/lib/forms.mjs +254 -0
- package/cloudflare/lib/pages-upload.mjs +51 -0
- package/cloudflare/lib/target.mjs +165 -0
- package/cloudflare/lib/workers-upload.mjs +48 -0
- package/cloudflare/lib/zones.mjs +25 -0
- package/cloudflare/mcp.json +4 -0
- package/cloudflare/plugin.json +96 -0
- package/cloudflare/skills/cloudflare/SKILL.md +57 -0
- package/cloudflare/skills/cloudflare/references/domains.md +31 -0
- package/cloudflare/skills/cloudflare/references/pages.md +18 -0
- package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
- package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
- package/cloudflare/skills/cloudflare/references/workers.md +42 -0
- package/cloudflare/targets/cloudflare-pages.mjs +12 -0
- package/cloudflare/targets/cloudflare-workers.mjs +17 -0
- package/cloudflare/tovu-consent.json +6 -0
- package/cloudflare/tovu-deploy-targets.json +630 -0
- package/cloudflare/tovu-form-destinations.json +12 -0
- package/cloudflare/worker/forms-receiver.mjs +249 -0
- package/composio/mcp.json +10 -0
- package/composio/plugin.json +68 -0
- package/composio/skills/composio/SKILL.md +85 -0
- package/dist/core/identity-namespace.d.ts +12 -0
- package/dist/core/identity-namespace.js +20 -0
- package/dist/folder-zip-node.js +29 -7
- package/dist/lifecycle/bundled-digests.js +4 -3
- package/dist/lifecycle/install.d.ts +7 -0
- package/dist/lifecycle/install.js +45 -3
- package/dist/lifecycle/manifest.d.ts +2 -2
- package/dist/lifecycle/manifest.js +5 -3
- package/dist/lifecycle/node.js +2 -0
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +8 -0
- package/dist/lifecycle/seed-bundled.js +4 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -2
- package/fly/deploy-configs/fly.mjs +115 -0
- package/fly/deploy-ops/fly.mjs +102 -0
- package/fly/mcp.json +4 -0
- package/fly/plugin.json +81 -0
- package/fly/scripts/render-deploy-workflow.mjs +150 -0
- package/fly/skills/fly/SKILL.md +43 -0
- package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
- package/fly/skills/fly/references/fly.template.toml +81 -0
- package/fly/tovu-credential-schemes.json +11 -0
- package/fly/tovu-deploy-configs.json +21 -0
- package/fly/tovu-deploy-ops.json +14 -0
- package/github/deploy-ops/github-actions.mjs +164 -0
- package/github/deployments/github.mjs +415 -0
- package/github/mcp.json +4 -0
- package/github/plugin.json +105 -0
- package/github/skills/github/SKILL.md +249 -0
- package/github/skills/github/references/actions.md +224 -0
- package/github/skills/github/references/auth-and-tokens.md +140 -0
- package/github/skills/github/references/repo-files.md +135 -0
- package/github/skills/github/references/site-backup.md +159 -0
- package/github/skills/github/references/source-control.md +123 -0
- package/github/source-control/backup-push.mjs +242 -0
- package/github/source-control/commit-site.mjs +459 -0
- package/github/source-control/github.mjs +93 -0
- package/github/source-control/write-files.mjs +468 -0
- package/github/tovu-deploy-ops.json +13 -0
- package/github/tovu-source-control.json +117 -0
- package/github-pages/NOTES.md +19 -0
- package/github-pages/mcp.json +4 -0
- package/github-pages/plugin.json +48 -0
- package/github-pages/skills/github-pages/SKILL.md +44 -0
- package/github-pages/targets/github-pages.mjs +543 -0
- package/github-pages/tovu-deploy-targets.json +327 -0
- package/google-login/exports/google-g.svg +1 -0
- package/google-login/exports/sign-in-button.css +68 -0
- package/google-login/exports/sign-in-button.html +4 -0
- package/google-login/mcp.json +4 -0
- package/google-login/plugin.json +59 -0
- package/google-login/skills/google-login/SKILL.md +96 -0
- package/google-login/tovu-login-providers.json +21 -0
- package/google-workspace/README.md +132 -0
- package/google-workspace/form-destinations/README.md +87 -0
- package/google-workspace/form-destinations/dedupe.mjs +43 -0
- package/google-workspace/form-destinations/delivery-result.mjs +69 -0
- package/google-workspace/form-destinations/mapping.mjs +152 -0
- package/google-workspace/form-destinations/sheets.mjs +202 -0
- package/google-workspace/lib/auth.mjs +81 -0
- package/google-workspace/lib/drive-files.mjs +54 -0
- package/google-workspace/lib/form-destination-settings.mjs +72 -0
- package/google-workspace/lib/http.mjs +168 -0
- package/google-workspace/lib/picker-flow.mjs +171 -0
- package/google-workspace/lib/picker-selection.mjs +107 -0
- package/google-workspace/lib/product-state.mjs +93 -0
- package/google-workspace/lib/request-policy.mjs +163 -0
- package/google-workspace/lib/sheets-a1.mjs +79 -0
- package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
- package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
- package/google-workspace/lib/sheets-client.mjs +286 -0
- package/google-workspace/lib/surface-actions.mjs +107 -0
- package/google-workspace/mcp.json +4 -0
- package/google-workspace/plugin.json +77 -0
- package/google-workspace/products.json +12 -0
- package/google-workspace/skills/google-workspace/SKILL.md +82 -0
- package/google-workspace/skills/google-workspace/references/connection.md +102 -0
- package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
- package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
- package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
- package/google-workspace/tools/account-info.mjs +18 -0
- package/google-workspace/tools/sheets/README.md +54 -0
- package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
- package/google-workspace/tools/sheets/copy.mjs +23 -0
- package/google-workspace/tools/sheets/create.mjs +35 -0
- package/google-workspace/tools/sheets/find-replace.mjs +38 -0
- package/google-workspace/tools/sheets/find.mjs +52 -0
- package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
- package/google-workspace/tools/sheets/get.mjs +22 -0
- package/google-workspace/tools/sheets/values-append.mjs +27 -0
- package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
- package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
- package/google-workspace/tools/sheets/values-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-get.mjs +21 -0
- package/google-workspace/tools/sheets/values-update.mjs +26 -0
- package/google-workspace/tools/sheets-admin/README.md +45 -0
- package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
- package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
- package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
- package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
- package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
- package/google-workspace/tools/sheets-admin/format.mjs +70 -0
- package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
- package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
- package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
- package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
- package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
- package/google-workspace/tovu-admin-surfaces.json +63 -0
- package/google-workspace/tovu-agent-tools.json +1178 -0
- package/google-workspace/tovu-connections.json +35 -0
- package/google-workspace/tovu-form-destinations.json +10 -0
- package/google-workspace/ui/README.md +73 -0
- package/google-workspace/ui/connection-panel.handler.mjs +167 -0
- package/google-workspace/ui/connection-panel.html +17 -0
- package/google-workspace/ui/connection-panel.mjs +189 -0
- package/google-workspace/ui/connection-view.mjs +120 -0
- package/google-workspace/ui/destination-panel.handler.mjs +106 -0
- package/google-workspace/ui/destination-panel.html +17 -0
- package/google-workspace/ui/destination-panel.mjs +150 -0
- package/google-workspace/ui/dom.mjs +140 -0
- package/google-workspace/ui/panel.css +170 -0
- package/google-workspace/ui/picker-flow.mjs +87 -0
- package/google-workspace/ui/picker-view.mjs +44 -0
- package/google-workspace/ui/picker.handler.mjs +56 -0
- package/google-workspace/ui/picker.html +17 -0
- package/google-workspace/ui/picker.mjs +88 -0
- package/google-workspace/ui/surface-client.mjs +227 -0
- package/higgsfield-media/mcp.json +9 -0
- package/higgsfield-media/plugin.json +109 -0
- package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
- package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
- package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
- package/jev/mcp.json +4 -0
- package/jev/plugin.json +44 -0
- package/jev/skills/jev/SKILL.md +308 -0
- package/jev/skills/jev/references/cookbooks.md +197 -0
- package/jev/skills/jev/references/jaggedness.md +135 -0
- package/jev/skills/jev/references/javascript-sdk.md +146 -0
- package/jev/skills/jev/references/patterns.md +123 -0
- package/jev/skills/jev/references/primitives-reference.md +187 -0
- package/langfuse/plugin.json +22 -0
- package/meilisearch/mcp.json +4 -0
- package/meilisearch/plugin.json +54 -0
- package/meilisearch/providers/meilisearch.mjs +651 -0
- package/meilisearch/skills/meilisearch/SKILL.md +43 -0
- package/meilisearch/tovu-search-providers.json +10 -0
- package/netlify/NOTES.md +13 -0
- package/netlify/mcp.json +4 -0
- package/netlify/plugin.json +47 -0
- package/netlify/skills/netlify/SKILL.md +39 -0
- package/netlify/targets/netlify.mjs +384 -0
- package/netlify/tovu-consent.json +6 -0
- package/netlify/tovu-deploy-targets.json +163 -0
- package/opentelemetry/mcp.json +4 -0
- package/opentelemetry/plugin.json +52 -0
- package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
- package/package.json +161 -6
- package/posthog/mcp.json +4 -0
- package/posthog/plugin.json +46 -0
- package/posthog/skills/posthog/SKILL.md +36 -0
- package/railway/deploy-configs/railway.mjs +135 -0
- package/railway/deploy-ops/railway.mjs +167 -0
- package/railway/mcp.json +4 -0
- package/railway/plugin.json +45 -0
- package/railway/skills/railway/SKILL.md +66 -0
- package/railway/tovu-deploy-configs.json +16 -0
- package/railway/tovu-deploy-ops.json +11 -0
- package/render/deploy-configs/render.mjs +141 -0
- package/render/deploy-ops/render.mjs +152 -0
- package/render/mcp.json +4 -0
- package/render/plugin.json +47 -0
- package/render/skills/render/SKILL.md +63 -0
- package/render/tovu-deploy-configs.json +16 -0
- package/render/tovu-deploy-ops.json +11 -0
- package/resend/NOTES.md +52 -0
- package/resend/adapters/resend.mjs +161 -0
- package/resend/lib/domains.mjs +137 -0
- package/resend/lib/errors.mjs +25 -0
- package/resend/lib/feedback.mjs +118 -0
- package/resend/mcp.json +13 -0
- package/resend/plugin.json +192 -0
- package/resend/skills/resend/SKILL.md +107 -0
- package/resend/tovu-mail-adapters.json +22 -0
- package/s3-compatible/NOTES.md +11 -0
- package/s3-compatible/mcp.json +4 -0
- package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
- package/s3-compatible/plugin.json +56 -0
- package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
- package/s3-compatible/targets/s3-compatible.mjs +1054 -0
- package/s3-compatible/tovu-deploy-targets.json +432 -0
- package/s3-compatible/tovu-object-stores.json +29 -0
- package/sentry/mcp.json +4 -0
- package/sentry/plugin.json +49 -0
- package/sentry/skills/sentry/SKILL.md +34 -0
- package/shopify/mcp.json +4 -0
- package/shopify/plugin.json +22 -0
- package/shopify/skills/shopify/SKILL.md +103 -0
- package/shopify/skills/shopify/references/catalog-sync.md +74 -0
- package/shopify/skills/shopify/references/domains.md +26 -0
- package/shopify/skills/shopify/references/live-storefront.md +34 -0
- package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
- package/site-compliance/mcp.json +4 -0
- package/site-compliance/plugin.json +34 -0
- package/site-compliance/skills/site-compliance/SKILL.md +220 -0
- package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
- package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
- package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
- package/site-import/mcp.json +4 -0
- package/site-import/plugin.json +41 -0
- package/site-import/skills/site-import/SKILL.md +232 -0
- package/site-import/skills/site-import/references/content-mapping.md +166 -0
- package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
- package/site-import/skills/site-import/references/theme-extraction.md +34 -0
- package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/slide-design/mcp.json +4 -0
- package/slide-design/plugin.json +26 -0
- package/slide-design/skills/slide-design/SKILL.md +103 -0
- package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
- package/slide-design/skills/slide-design/references/html-deck.md +175 -0
- package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
- package/slide-design/skills/slide-design/references/review.md +93 -0
- package/slide-design/skills/slide-design/references/storyline.md +118 -0
- package/stripe/plugin.json +22 -0
- package/supabase/mcp.json +26 -0
- package/supabase/plugin.json +78 -0
- package/supabase/skills/supabase/SKILL.md +169 -0
- package/supabase/skills/supabase/references/failure-modes.md +101 -0
- package/ui-ux-design/CHANGELOG.md +28 -0
- package/ui-ux-design/LICENSE +201 -0
- package/ui-ux-design/NOTICE +14 -0
- package/ui-ux-design/README.md +62 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +26 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
- package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
- package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
- package/vercel/NOTES.md +23 -0
- package/vercel/mcp.json +4 -0
- package/vercel/object-stores/vercel-blob.mjs +147 -0
- package/vercel/plugin.json +47 -0
- package/vercel/skills/vercel/SKILL.md +48 -0
- package/vercel/targets/vercel.mjs +586 -0
- package/vercel/tovu-consent.json +6 -0
- package/vercel/tovu-deploy-targets.json +219 -0
- package/vercel/tovu-object-stores.json +10 -0
- package/wordpress/NOTES.md +33 -0
- package/wordpress/plugin.json +30 -0
- package/wordpress/skills/wordpress/SKILL.md +43 -0
- package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
- package/wordpress/skills/wordpress/references/subdomain.md +66 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Meilisearch search provider for the `meilisearch` Agent Plugin.
|
|
5
|
+
*
|
|
6
|
+
* Implements the generic site-search provider contract (`@jini-ai/search`'s `SearchProvider`) on a
|
|
7
|
+
* Meilisearch v1.x server. The host loads this file through its search-provider registry and hands it a
|
|
8
|
+
* per-call context: the index name, the saved `host` + `apiKey` credentials and the guarded
|
|
9
|
+
* outbound-HTTP client, so every request still goes through the host's egress policy. Plain JS, no npm
|
|
10
|
+
* imports: an installed plugin has none.
|
|
11
|
+
*
|
|
12
|
+
* PUBLIC CONTENT ONLY. Meilisearch is a third party the site owner may not operate, so the provider
|
|
13
|
+
* declares `acceptsRestricted: false`; the indexer already turns restricted documents into removals
|
|
14
|
+
* for it, and `upsert` refuses one per key anyway (defence in depth), so restricted text never
|
|
15
|
+
* reaches a request body.
|
|
16
|
+
*
|
|
17
|
+
* Contract rules and how they map onto Meilisearch:
|
|
18
|
+
* - Methods never throw. A transport fault, a 5xx or an unparseable success is `unknown` (it may
|
|
19
|
+
* have happened); a definite refusal is `rejected`. No message ever contains the API key.
|
|
20
|
+
* - Writes are asynchronous: `POST /indexes/{uid}/documents` answers `202 {taskUid}`, reported as
|
|
21
|
+
* `pending` with that task id, settled through `awaitTask` (`GET /tasks/{uid}`).
|
|
22
|
+
* - A removal is a TOMBSTONE document (`deleted: true`, empty title/body/url, no fields), written with
|
|
23
|
+
* full-replace semantics so none of the old content survives, and every query filters
|
|
24
|
+
* `deleted = false`. Keeping the tombstone (instead of deleting the document) is what lets a later,
|
|
25
|
+
* delayed lower revision be recognised as stale instead of resurrecting the document.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {{ siteId: string, workspaceId: string, collection: string, documentId: string }} SearchDocumentKey
|
|
28
|
+
* @typedef {string | number | boolean | readonly string[]} SearchFieldValue
|
|
29
|
+
* @typedef {{ kind: 'public' } | { kind: 'restricted', audiences: readonly string[] }} SearchVisibility
|
|
30
|
+
* @typedef {{ key: SearchDocumentKey, revision: number, locale?: string, url: string, title: string, body: string,
|
|
31
|
+
* fields?: Readonly<Record<string, SearchFieldValue>>, visibility: SearchVisibility }} SearchDocument
|
|
32
|
+
* @typedef {{ key: SearchDocumentKey, revision: number }} SearchTombstone
|
|
33
|
+
* @typedef {{ text: string, collections?: readonly string[], locale?: string,
|
|
34
|
+
* filters?: Readonly<Record<string, string | number | boolean>>, audiences?: readonly string[], limit?: number, cursor?: string }} SearchQuery
|
|
35
|
+
* @typedef {{ start: number, end: number }} SearchHighlight
|
|
36
|
+
* @typedef {{ key: SearchDocumentKey, revision: number, url: string, title: string, snippet: string,
|
|
37
|
+
* highlights: SearchHighlight[], score: number }} SearchHit
|
|
38
|
+
* @typedef {{ key: SearchDocumentKey, revision: number, status: 'applied' | 'stale' | 'pending' | 'rejected', message?: string }} SearchWriteOutcome
|
|
39
|
+
* @typedef {{ key: SearchDocumentKey, revision: number | null, deleted: boolean }} SearchRevisionState
|
|
40
|
+
* @typedef {{ method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, headers: Record<string, string>, body?: string, idleTimeoutMs: number }} HttpRequest
|
|
41
|
+
* @typedef {{ status: number, headers: Readonly<Record<string, string>>, bodyText: string }} HttpResponse
|
|
42
|
+
* @typedef {{ send(required: { request: HttpRequest }): Promise<HttpResponse> }} HttpClient
|
|
43
|
+
* @typedef {{ workspaceId: string, siteId: string, indexName: string, credentials: Readonly<Record<string, string>>,
|
|
44
|
+
* httpClient: HttpClient, timeoutMs: number, now: () => number }} SearchProviderContext
|
|
45
|
+
* @typedef {{ code: string, message: string, retryAfterMs?: number }} ProviderError
|
|
46
|
+
* @typedef {{ status: 'ok', value: any } | { status: 'rejected', error: ProviderError } | { status: 'unknown', error: ProviderError }} ProviderResult
|
|
47
|
+
* @typedef {{ origin: string, apiKey: string, index: string, ctx: SearchProviderContext }} Connection
|
|
48
|
+
* @typedef {{ ok: true, status: number, json: any } | { ok: false, result: ProviderResult, code?: string }} CallResult
|
|
49
|
+
* @typedef {{ revision: number, deleted: boolean }} StoredRevision
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const PROVIDER_ID = "meilisearch";
|
|
53
|
+
|
|
54
|
+
/** Matches the declared capability; the indexer chunks to it, `write()` refuses more (defence). */
|
|
55
|
+
const MAX_BATCH = 500;
|
|
56
|
+
|
|
57
|
+
/** Meilisearch document ids are at most 511 bytes of `[A-Za-z0-9_-]`. */
|
|
58
|
+
const MAX_DOCUMENT_ID_LENGTH = 511;
|
|
59
|
+
|
|
60
|
+
const DEFAULT_LIMIT = 10;
|
|
61
|
+
const MAX_LIMIT = 100;
|
|
62
|
+
|
|
63
|
+
/** Words of body context Meilisearch keeps around the match in `_formatted.body`. */
|
|
64
|
+
const CROP_LENGTH = 30;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Highlight delimiters. Control characters a plain-text body never legitimately carries, so the
|
|
68
|
+
* snippet can be rebuilt as plain text plus offsets; markup tags (Meilisearch's default `<em>`)
|
|
69
|
+
* would put HTML into a snippet, which the contract forbids.
|
|
70
|
+
*/
|
|
71
|
+
const HIGHLIGHT_START = "\u0001";
|
|
72
|
+
const HIGHLIGHT_END = "\u0002";
|
|
73
|
+
/** Splits a formatted body into text segments and the delimiters themselves (kept by the group). */
|
|
74
|
+
const HIGHLIGHT_SPLIT = new RegExp(`([${HIGHLIGHT_START}${HIGHLIGHT_END}])`);
|
|
75
|
+
|
|
76
|
+
/** First and maximum pause between task polls in `waitForTask`. */
|
|
77
|
+
const FIRST_POLL_DELAY_MS = 25;
|
|
78
|
+
const MAX_POLL_DELAY_MS = 250;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One filterable attribute for every exact-match field, holding `JSON.stringify([name, value])`
|
|
82
|
+
* tokens (one per array element). Why not one `f_<name>` attribute per field: `filterableAttributes`
|
|
83
|
+
* must name each attribute up front (wildcard patterns exist only in recent Meilisearch releases),
|
|
84
|
+
* and field names are only known when documents arrive. The JSON token also keeps exact-match typed:
|
|
85
|
+
* the string `"3"` never matches the number `3`.
|
|
86
|
+
*/
|
|
87
|
+
const FIELDS_ATTRIBUTE = "f_fields";
|
|
88
|
+
|
|
89
|
+
const INDEX_SETTINGS = {
|
|
90
|
+
// `id` is filterable because the stale check reads current revisions with `id IN [...]`.
|
|
91
|
+
filterableAttributes: ["id", "site_id", "workspace_id", "collection", "locale", "deleted", FIELDS_ATTRIBUTE],
|
|
92
|
+
searchableAttributes: ["title", "body"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const HIT_ATTRIBUTES = ["site_id", "workspace_id", "collection", "document_id", "revision", "url", "title", "body"];
|
|
96
|
+
|
|
97
|
+
/** @param {any} value @returns {ProviderResult} */
|
|
98
|
+
const ok = (value) => ({ status: "ok", value });
|
|
99
|
+
/** @param {string} code @param {string} message @param {number} [retryAfterMs] @returns {ProviderResult} */
|
|
100
|
+
const rejected = (code, message, retryAfterMs) => ({ status: "rejected", error: retryAfterMs === undefined ? { code, message } : { code, message, retryAfterMs } });
|
|
101
|
+
/** @param {string} code @param {string} message @returns {ProviderResult} */
|
|
102
|
+
const unknown = (code, message) => ({ status: "unknown", error: { code, message } });
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------------------------
|
|
105
|
+
// Keys and documents
|
|
106
|
+
// ---------------------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Byte-identical copy of `@jini-ai/search`'s `encodeSearchKey` (a plugin cannot import it); a test
|
|
110
|
+
* pins the two together. Each part is URI-encoded so a `/` inside one part cannot forge another key.
|
|
111
|
+
* @param {SearchDocumentKey} key
|
|
112
|
+
*/
|
|
113
|
+
function encodeSearchKey(key) {
|
|
114
|
+
return [key.siteId, key.workspaceId, key.collection, key.documentId].map((part) => encodeURIComponent(part)).join("/");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The Meilisearch document id of a key: base64url of the encoded key. Meilisearch ids allow only
|
|
119
|
+
* `[A-Za-z0-9_-]`; the encoded key is ASCII (URI-encoded), so `btoa` is lossless here.
|
|
120
|
+
* @param {SearchDocumentKey} key
|
|
121
|
+
*/
|
|
122
|
+
function documentIdOf(key) {
|
|
123
|
+
return btoa(encodeSearchKey(key)).replace(/\+/g, "-").replace(/\//g, "_").replace(/={1,2}$/, "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {SearchDocumentKey} key */
|
|
127
|
+
function keyColumns(key) {
|
|
128
|
+
return { id: documentIdOf(key), site_id: key.siteId, workspace_id: key.workspaceId, collection: key.collection, document_id: key.documentId };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {Readonly<Record<string, SearchFieldValue>>} fields @returns {string[]} */
|
|
132
|
+
function fieldTokens(fields) {
|
|
133
|
+
return Object.entries(fields).flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map((item) => JSON.stringify([name, item])));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {SearchDocument} document */
|
|
137
|
+
function toMeiliDocument(document) {
|
|
138
|
+
return {
|
|
139
|
+
...keyColumns(document.key), revision: document.revision, deleted: false, url: document.url, title: document.title,
|
|
140
|
+
body: document.body, locale: document.locale ?? null, [FIELDS_ATTRIBUTE]: fieldTokens(document.fields ?? {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A tombstone carries the key and revision only: no title, body, url or field can leak through it. @param {SearchTombstone} tombstone */
|
|
145
|
+
function toMeiliTombstone(tombstone) {
|
|
146
|
+
return { ...keyColumns(tombstone.key), revision: tombstone.revision, deleted: true, url: "", title: "", body: "", locale: null, [FIELDS_ATTRIBUTE]: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------------------------
|
|
150
|
+
// Transport and error mapping
|
|
151
|
+
// ---------------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** @param {unknown} value */
|
|
154
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
155
|
+
|
|
156
|
+
/** @param {SearchProviderContext} ctx @returns {Connection | ProviderResult} */
|
|
157
|
+
function connect(ctx) {
|
|
158
|
+
const { host, apiKey } = ctx.credentials ?? {};
|
|
159
|
+
if (!isNonEmptyString(host) || !isNonEmptyString(apiKey)) return rejected("not_configured", "Meilisearch needs both a host and an apiKey credential");
|
|
160
|
+
if (!/^https?:\/\//i.test(host)) return rejected("not_configured", "the Meilisearch host must be an http(s) URL");
|
|
161
|
+
let origin = host.trim();
|
|
162
|
+
while (origin.endsWith("/")) origin = origin.slice(0, -1);
|
|
163
|
+
return { origin, apiKey, index: encodeURIComponent(ctx.indexName), ctx };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {Connection | ProviderResult} value @returns {value is ProviderResult} */
|
|
167
|
+
const isResult = (value) => "status" in value;
|
|
168
|
+
|
|
169
|
+
/** @param {string} text */
|
|
170
|
+
function parseJson(text) {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(text);
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {Readonly<Record<string, string>>} headers @param {string} name */
|
|
179
|
+
function headerOf(headers, name) {
|
|
180
|
+
const found = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name);
|
|
181
|
+
return found?.[1];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `Retry-After` in milliseconds: delta-seconds or an HTTP date (measured with the injected clock).
|
|
186
|
+
* @param {string | undefined} value @param {() => number} now @returns {number | undefined}
|
|
187
|
+
*/
|
|
188
|
+
function parseRetryAfter(value, now) {
|
|
189
|
+
if (value === undefined) return undefined;
|
|
190
|
+
if (/^\s*\d+\s*$/.test(value)) return Number(value) * 1000;
|
|
191
|
+
const at = Date.parse(value);
|
|
192
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** ` (code)` for a message, or nothing when Meilisearch named no code. @param {string | undefined} code */
|
|
196
|
+
const codeSuffix = (code) => (code === undefined ? "" : ` (${code})`);
|
|
197
|
+
|
|
198
|
+
/** Meilisearch's error `code` (e.g. `index_not_found`), when the body is its JSON error. @param {any} json */
|
|
199
|
+
const errorCodeOf = (json) => (typeof json?.code === "string" ? json.code : undefined);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A non-2xx answer as a contract result. Messages name the status and Meilisearch's error code only,
|
|
203
|
+
* never the vendor's free-text message (it can echo request content) and never the key.
|
|
204
|
+
* @param {HttpResponse} response @param {any} json @param {() => number} now @returns {ProviderResult}
|
|
205
|
+
*/
|
|
206
|
+
function mapFailure(response, json, now) {
|
|
207
|
+
const { status } = response;
|
|
208
|
+
const code = errorCodeOf(json);
|
|
209
|
+
const detail = `Meilisearch answered HTTP ${status}${codeSuffix(code)}`;
|
|
210
|
+
if (status >= 500) return unknown("provider_unavailable", detail);
|
|
211
|
+
if (status === 429) return rejected("rate_limited", detail, parseRetryAfter(headerOf(response.headers, "retry-after"), now));
|
|
212
|
+
if (status === 401) return rejected("unauthorized", `${detail}: the API key was not accepted`);
|
|
213
|
+
if (status === 403) return rejected("forbidden", `${detail}: the API key lacks a required permission`);
|
|
214
|
+
if (status === 404) return rejected("not_found", detail);
|
|
215
|
+
if (status >= 400) return rejected(json?.type === "invalid_request" ? "invalid_request" : "provider_error", detail);
|
|
216
|
+
return unknown("unexpected_response", detail);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A thrown transport error as `unknown`: `timeout` when it looks like an abort or timeout. @param {unknown} error @returns {ProviderResult} */
|
|
220
|
+
function mapTransportError(error) {
|
|
221
|
+
const name = error instanceof Error ? error.name : "";
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
if (/abort|timeout/i.test(name) || /timed? ?out|abort/i.test(message)) return unknown("timeout", "the Meilisearch request timed out");
|
|
224
|
+
return unknown("network", "Meilisearch could not be reached");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One request through the host's guarded client. Never throws.
|
|
229
|
+
* @param {Connection} conn @param {'GET' | 'POST' | 'PATCH' | 'DELETE'} method @param {string} path @param {unknown} [body]
|
|
230
|
+
* @returns {Promise<CallResult>}
|
|
231
|
+
*/
|
|
232
|
+
async function call(conn, method, path, body) {
|
|
233
|
+
/** @type {HttpRequest} */
|
|
234
|
+
const request = {
|
|
235
|
+
method, url: `${conn.origin}${path}`, idleTimeoutMs: conn.ctx.timeoutMs,
|
|
236
|
+
headers: { Authorization: `Bearer ${conn.apiKey}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
237
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
238
|
+
};
|
|
239
|
+
let response;
|
|
240
|
+
try {
|
|
241
|
+
response = await conn.ctx.httpClient.send({ request });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return { ok: false, result: mapTransportError(error) };
|
|
244
|
+
}
|
|
245
|
+
const json = parseJson(response.bodyText);
|
|
246
|
+
if (response.status < 200 || response.status >= 300) {
|
|
247
|
+
const code = errorCodeOf(json);
|
|
248
|
+
return { ok: false, result: mapFailure(response, json, conn.ctx.now), ...(code === undefined ? {} : { code }) };
|
|
249
|
+
}
|
|
250
|
+
if (json === undefined) return { ok: false, result: unknown("unexpected_response", `Meilisearch answered HTTP ${response.status} with a body that is not JSON`) };
|
|
251
|
+
return { ok: true, status: response.status, json };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @param {CallResult} result @param {string} code */
|
|
255
|
+
const failedWith = (result, code) => !result.ok && result.code === code;
|
|
256
|
+
|
|
257
|
+
/** @param {CallResult} result */
|
|
258
|
+
const isIndexNotFound = (result) => failedWith(result, "index_not_found");
|
|
259
|
+
|
|
260
|
+
/** @param {any} json @returns {string | undefined} */
|
|
261
|
+
const taskUidOf = (json) => (Number.isSafeInteger(json?.taskUid) ? String(json.taskUid) : undefined);
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------------------------
|
|
264
|
+
// Tasks
|
|
265
|
+
// ---------------------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Why a real timer: the contract has no sleep port, and only `ensureIndex`/`dropIndex` wait (their
|
|
269
|
+
* contract is "done when ok"), plus a write's queue drain (an EARLIER task, see `drainQueue`); a
|
|
270
|
+
* write never waits for its own task, it answers `pending` and the indexer settles it.
|
|
271
|
+
* @param {number} ms
|
|
272
|
+
*/
|
|
273
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A Meilisearch task status as the contract's task state, or `undefined` for anything else.
|
|
277
|
+
* @param {unknown} status @returns {'succeeded' | 'failed' | 'pending' | undefined}
|
|
278
|
+
*/
|
|
279
|
+
function taskStateOf(status) {
|
|
280
|
+
if (status === "succeeded") return "succeeded";
|
|
281
|
+
if (status === "failed" || status === "canceled") return "failed";
|
|
282
|
+
if (status === "enqueued" || status === "processing") return "pending";
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @param {Connection} conn @param {string} taskId @returns {Promise<{ result: ProviderResult, errorCode?: string }>} */
|
|
287
|
+
async function readTask(conn, taskId) {
|
|
288
|
+
const response = await call(conn, "GET", `/tasks/${encodeURIComponent(taskId)}`);
|
|
289
|
+
if (!response.ok) return { result: response.result };
|
|
290
|
+
const state = taskStateOf(response.json?.status);
|
|
291
|
+
if (state === undefined) return { result: unknown("unexpected_response", `Meilisearch task ${taskId} has an unrecognised status`) };
|
|
292
|
+
return { result: ok(state), errorCode: errorCodeOf(response.json?.error) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* One poll: the task's final result, or `undefined` while it is still pending.
|
|
297
|
+
* `tolerated` names task error codes that still mean success (e.g. deleting an absent index).
|
|
298
|
+
* @param {Connection} conn @param {string} taskId @param {readonly string[]} tolerated @returns {Promise<ProviderResult | undefined>}
|
|
299
|
+
*/
|
|
300
|
+
async function settledTask(conn, taskId, tolerated) {
|
|
301
|
+
const { result, errorCode } = await readTask(conn, taskId);
|
|
302
|
+
if (result.status !== "ok") return result;
|
|
303
|
+
if (result.value === "succeeded" || (errorCode !== undefined && tolerated.includes(errorCode))) return ok(undefined);
|
|
304
|
+
if (result.value === "failed") return rejected("provider_error", `Meilisearch task ${taskId} failed${codeSuffix(errorCode)}`);
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Polls a task, pausing between polls, until it settles or `ctx.timeoutMs` (measured with the
|
|
310
|
+
* injected clock) runs out.
|
|
311
|
+
* @param {Connection} conn @param {string} taskId @param {readonly string[]} tolerated @returns {Promise<ProviderResult>}
|
|
312
|
+
*/
|
|
313
|
+
async function waitForTask(conn, taskId, tolerated) {
|
|
314
|
+
const { now, timeoutMs } = conn.ctx;
|
|
315
|
+
const deadline = now() + timeoutMs;
|
|
316
|
+
let delay = FIRST_POLL_DELAY_MS;
|
|
317
|
+
let settled = await settledTask(conn, taskId, tolerated);
|
|
318
|
+
while (settled === undefined && now() < deadline) {
|
|
319
|
+
await pause(delay);
|
|
320
|
+
delay = Math.min(delay * 2, MAX_POLL_DELAY_MS);
|
|
321
|
+
settled = await settledTask(conn, taskId, tolerated);
|
|
322
|
+
}
|
|
323
|
+
return settled ?? unknown("timeout", `Meilisearch task ${taskId} was still pending after ${timeoutMs} ms`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** @param {CallResult} response @param {string} what @returns {ProviderResult | string} */
|
|
327
|
+
function enqueuedTask(response, what) {
|
|
328
|
+
if (!response.ok) return response.result;
|
|
329
|
+
return taskUidOf(response.json) ?? unknown("unexpected_response", `Meilisearch accepted ${what} without a task uid`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------------------------
|
|
333
|
+
// Write serialization (revision decisions never overlap)
|
|
334
|
+
// ---------------------------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** @type {Map<string, Promise<void>>} The tail of each index's write lane in this process. */
|
|
337
|
+
const lanes = new Map();
|
|
338
|
+
|
|
339
|
+
const settledQuietly = () => undefined;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs `step` after every earlier write of this process to the same index has enqueued its task.
|
|
343
|
+
* Coordination only, no data: a lane holds a promise and is removed once it drains.
|
|
344
|
+
* @template T @param {string} index @param {() => Promise<T>} step @returns {Promise<T>}
|
|
345
|
+
*/
|
|
346
|
+
async function inLane(index, step) {
|
|
347
|
+
const run = (lanes.get(index) ?? Promise.resolve()).then(step);
|
|
348
|
+
const tail = run.then(settledQuietly, settledQuietly);
|
|
349
|
+
lanes.set(index, tail);
|
|
350
|
+
try {
|
|
351
|
+
return await run;
|
|
352
|
+
} finally {
|
|
353
|
+
if (lanes.get(index) === tail) lanes.delete(index);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Waits until no task of this index is still queued, so a revision read afterwards sees every
|
|
359
|
+
* earlier write applied, also one queued by another process or before a restart (the durable
|
|
360
|
+
* coordination is Meilisearch's own task queue). Tasks run in enqueue order, so the newest unfinished
|
|
361
|
+
* task settling means every earlier one did. A failed task is settled too; only an indeterminate wait
|
|
362
|
+
* is returned (as `unknown`: the write is not attempted, and the indexer reconciles it).
|
|
363
|
+
* @param {Connection} conn @returns {Promise<ProviderResult | undefined>}
|
|
364
|
+
*/
|
|
365
|
+
async function drainQueue(conn) {
|
|
366
|
+
const response = await call(conn, "GET", `/tasks?indexUids=${conn.index}&statuses=enqueued,processing&limit=1`);
|
|
367
|
+
if (!response.ok) return response.result;
|
|
368
|
+
const results = response.json?.results;
|
|
369
|
+
if (!Array.isArray(results)) return unknown("unexpected_response", "Meilisearch listed tasks without a results array");
|
|
370
|
+
if (results.length === 0) return undefined;
|
|
371
|
+
const uid = results[0]?.uid;
|
|
372
|
+
if (!Number.isSafeInteger(uid)) return unknown("unexpected_response", "Meilisearch listed a task without a uid");
|
|
373
|
+
const settled = await waitForTask(conn, String(uid), []);
|
|
374
|
+
return settled.status === "unknown" ? settled : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------------------------
|
|
378
|
+
// Revisions (the stale check and revisionOf)
|
|
379
|
+
// ---------------------------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/** @param {any} entry */
|
|
382
|
+
const isStoredRevision = (entry) => typeof entry?.id === "string" && Number.isSafeInteger(entry?.revision);
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Current revisions by Meilisearch id. An absent index holds nothing, so it is an empty map.
|
|
386
|
+
* @param {Connection} conn @param {readonly string[]} ids @returns {Promise<Map<string, StoredRevision> | ProviderResult>}
|
|
387
|
+
*/
|
|
388
|
+
async function fetchRevisions(conn, ids) {
|
|
389
|
+
const stored = new Map();
|
|
390
|
+
if (ids.length === 0) return stored;
|
|
391
|
+
const body = { filter: `id IN [${ids.map((id) => JSON.stringify(id)).join(", ")}]`, fields: ["id", "revision", "deleted"], limit: ids.length };
|
|
392
|
+
const response = await call(conn, "POST", `/indexes/${conn.index}/documents/fetch`, body);
|
|
393
|
+
if (isIndexNotFound(response)) return stored;
|
|
394
|
+
if (!response.ok) return response.result;
|
|
395
|
+
const results = response.json?.results;
|
|
396
|
+
if (!Array.isArray(results) || !results.every(isStoredRevision)) return unknown("unexpected_response", "Meilisearch returned documents without an id and revision");
|
|
397
|
+
for (const entry of results) stored.set(entry.id, { revision: entry.revision, deleted: entry.deleted === true });
|
|
398
|
+
return stored;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------------------------
|
|
402
|
+
// Writes
|
|
403
|
+
// ---------------------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Why a key is refused before anything is sent, or `undefined` when it may be written.
|
|
407
|
+
* @param {{ key: SearchDocumentKey, revision: number, visibility?: SearchVisibility }} item @returns {string | undefined}
|
|
408
|
+
*/
|
|
409
|
+
function refusalOf(item) {
|
|
410
|
+
// Defence in depth: the indexer already turns restricted documents into tombstones for this
|
|
411
|
+
// provider; a restricted document reaching here is still never put in a request body.
|
|
412
|
+
if (item.visibility !== undefined && item.visibility.kind !== "public") return "restricted documents are never sent to Meilisearch (public content only)";
|
|
413
|
+
if (!Number.isSafeInteger(item.revision) || item.revision < 0) return "revision must be a non-negative safe integer";
|
|
414
|
+
if (documentIdOf(item.key).length > MAX_DOCUMENT_ID_LENGTH) return "the document key is too long for a Meilisearch document id";
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Per-key outcome of the stale check. Revisions strictly increase per key, so an equal or lower
|
|
420
|
+
* incoming revision (also against a tombstone, also against an earlier item of the same batch) is
|
|
421
|
+
* `stale`. Why reading then writing is race-free: the read only ever runs inside the index's write
|
|
422
|
+
* lane AFTER every earlier document task of the index has settled (`drainQueue`), and the queued
|
|
423
|
+
* write is enqueued before the lane is released, so no write can land between this read and the
|
|
424
|
+
* write it decides. Being the indexer's ONE writer alone is not enough: two overlapping indexer
|
|
425
|
+
* calls (an event and a reconcile retry) would otherwise both read before either task ran.
|
|
426
|
+
* @template {{ key: SearchDocumentKey, revision: number, visibility?: SearchVisibility }} I
|
|
427
|
+
* @param {readonly I[]} items @param {Map<string, StoredRevision>} stored @param {(item: I) => object} toDocument
|
|
428
|
+
*/
|
|
429
|
+
function planWrites(items, stored, toDocument) {
|
|
430
|
+
/** @type {Map<string, number>} */
|
|
431
|
+
const highest = new Map([...stored].map(([id, entry]) => [id, entry.revision]));
|
|
432
|
+
/** @type {Map<string, object>} */
|
|
433
|
+
const send = new Map();
|
|
434
|
+
const outcomes = items.map((item) => {
|
|
435
|
+
const refusal = refusalOf(item);
|
|
436
|
+
if (refusal !== undefined) return { key: item.key, revision: item.revision, status: "rejected", message: refusal };
|
|
437
|
+
const id = documentIdOf(item.key);
|
|
438
|
+
if ((highest.get(id) ?? -1) >= item.revision) return { key: item.key, revision: item.revision, status: "stale" };
|
|
439
|
+
highest.set(id, item.revision);
|
|
440
|
+
send.set(id, toDocument(item));
|
|
441
|
+
return { key: item.key, revision: item.revision, status: "pending" };
|
|
442
|
+
});
|
|
443
|
+
return { outcomes, documents: [...send.values()] };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** @param {readonly { key: SearchDocumentKey, revision: number, visibility?: SearchVisibility }[]} items */
|
|
447
|
+
const writableIds = (items) => items.filter((item) => refusalOf(item) === undefined).map((item) => documentIdOf(item.key));
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The shared write path of `upsert` and `remove`.
|
|
451
|
+
* @template {{ key: SearchDocumentKey, revision: number, visibility?: SearchVisibility }} I
|
|
452
|
+
* @param {SearchProviderContext} ctx @param {readonly I[]} items @param {(item: I) => object} toDocument @returns {Promise<ProviderResult>}
|
|
453
|
+
*/
|
|
454
|
+
async function write(ctx, items, toDocument) {
|
|
455
|
+
const conn = connect(ctx);
|
|
456
|
+
if (isResult(conn)) return conn;
|
|
457
|
+
if (items.length > MAX_BATCH) return rejected("invalid_request", `Meilisearch accepts at most ${MAX_BATCH} documents per write`);
|
|
458
|
+
return inLane(conn.index, () => decideAndWrite(conn, items, toDocument));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The serialized half of a write: wait for the index's queue, read revisions, enqueue the write.
|
|
463
|
+
* @template {{ key: SearchDocumentKey, revision: number, visibility?: SearchVisibility }} I
|
|
464
|
+
* @param {Connection} conn @param {readonly I[]} items @param {(item: I) => object} toDocument @returns {Promise<ProviderResult>}
|
|
465
|
+
*/
|
|
466
|
+
async function decideAndWrite(conn, items, toDocument) {
|
|
467
|
+
const ids = writableIds(items);
|
|
468
|
+
// Nothing writable (every key refused): no revision decision to protect, so no request at all.
|
|
469
|
+
const blocked = ids.length === 0 ? undefined : await drainQueue(conn);
|
|
470
|
+
if (blocked !== undefined) return blocked;
|
|
471
|
+
const stored = await fetchRevisions(conn, ids);
|
|
472
|
+
if (!(stored instanceof Map)) return stored;
|
|
473
|
+
const { outcomes, documents } = planWrites(items, stored, toDocument);
|
|
474
|
+
if (documents.length === 0) return ok({ outcomes });
|
|
475
|
+
const task = enqueuedTask(await call(conn, "POST", `/indexes/${conn.index}/documents?primaryKey=id`, documents), "the documents");
|
|
476
|
+
if (typeof task !== "string") return task;
|
|
477
|
+
return ok({ outcomes, taskId: task });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------------------------
|
|
481
|
+
// Queries
|
|
482
|
+
// ---------------------------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/** A string literal in Meilisearch's filter grammar. @param {string} value */
|
|
485
|
+
const filterString = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The query's filter. Site and workspace come from the context, never the query. `audiences` is not
|
|
489
|
+
* filtered on: this index only ever holds public documents (restricted ones are refused on write).
|
|
490
|
+
* @param {SearchProviderContext} ctx @param {SearchQuery} query
|
|
491
|
+
*/
|
|
492
|
+
function filterOf(ctx, query) {
|
|
493
|
+
const clauses = ["deleted = false", `site_id = ${filterString(ctx.siteId)}`, `workspace_id = ${filterString(ctx.workspaceId)}`];
|
|
494
|
+
if (query.collections !== undefined) clauses.push(`collection IN [${query.collections.map(filterString).join(", ")}]`);
|
|
495
|
+
if (query.locale !== undefined) clauses.push(`locale = ${filterString(query.locale)}`);
|
|
496
|
+
for (const [name, value] of Object.entries(query.filters ?? {})) clauses.push(`${FIELDS_ATTRIBUTE} = ${filterString(JSON.stringify([name, value]))}`);
|
|
497
|
+
return clauses.join(" AND ");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** @param {SearchQuery} query @returns {{ limit: number, offset: number } | string} */
|
|
501
|
+
function pageOf(query) {
|
|
502
|
+
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
503
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) return `limit must be an integer from 1 to ${MAX_LIMIT}`;
|
|
504
|
+
const cursor = query.cursor ?? "0";
|
|
505
|
+
if (!/^\d{1,9}$/.test(cursor)) return "cursor is not a cursor of this provider";
|
|
506
|
+
return { limit, offset: Number(cursor) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Plain text: markup tags removed, then any stray angle bracket, then control characters turned
|
|
511
|
+
* into spaces (a snippet is rendered by hosts that may not escape it; it must never carry HTML).
|
|
512
|
+
* @param {string} text
|
|
513
|
+
*/
|
|
514
|
+
const plainText = (text) => text.replace(/<[^<>]*>/g, "").replace(/[<>]/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Rebuilds a plain-text snippet and highlight offsets from a cropped body delimited with
|
|
518
|
+
* {@link HIGHLIGHT_START}/{@link HIGHLIGHT_END}. Each segment is cleaned before offsets are taken,
|
|
519
|
+
* so every range indexes the returned snippet exactly.
|
|
520
|
+
* @param {string} formatted @returns {{ snippet: string, highlights: SearchHighlight[] }}
|
|
521
|
+
*/
|
|
522
|
+
function snippetOf(formatted) {
|
|
523
|
+
let snippet = "";
|
|
524
|
+
let highlighted = false;
|
|
525
|
+
/** @type {SearchHighlight[]} */
|
|
526
|
+
const highlights = [];
|
|
527
|
+
for (const segment of formatted.split(HIGHLIGHT_SPLIT)) {
|
|
528
|
+
if (segment === HIGHLIGHT_START || segment === HIGHLIGHT_END) {
|
|
529
|
+
highlighted = segment === HIGHLIGHT_START;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const text = plainText(segment);
|
|
533
|
+
if (highlighted && text.length > 0) highlights.push({ start: snippet.length, end: snippet.length + text.length });
|
|
534
|
+
snippet += text;
|
|
535
|
+
}
|
|
536
|
+
return { snippet, highlights };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** @param {any} hit */
|
|
540
|
+
const isHit = (hit) => HIT_ATTRIBUTES.every((name) => (name === "revision" ? Number.isSafeInteger(hit?.revision) : typeof hit?.[name] === "string"));
|
|
541
|
+
|
|
542
|
+
/** @param {any} hit @returns {SearchHit} */
|
|
543
|
+
function toHit(hit) {
|
|
544
|
+
const formatted = typeof hit._formatted?.body === "string" ? hit._formatted.body : hit.body;
|
|
545
|
+
return {
|
|
546
|
+
key: { siteId: hit.site_id, workspaceId: hit.workspace_id, collection: hit.collection, documentId: hit.document_id },
|
|
547
|
+
revision: hit.revision, url: hit.url, title: hit.title, ...snippetOf(formatted),
|
|
548
|
+
score: typeof hit._rankingScore === "number" ? hit._rankingScore : 0,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* One page of hits. One extra hit is requested: its presence is what proves a next page exists,
|
|
554
|
+
* without trusting Meilisearch's `estimatedTotalHits` (an estimate, by name).
|
|
555
|
+
* @param {Connection} conn @param {SearchQuery} query @param {{ limit: number, offset: number }} page @returns {Promise<ProviderResult>}
|
|
556
|
+
*/
|
|
557
|
+
async function search(conn, query, page) {
|
|
558
|
+
const body = {
|
|
559
|
+
q: query.text, filter: filterOf(conn.ctx, query), limit: page.limit + 1, offset: page.offset, attributesToRetrieve: HIT_ATTRIBUTES,
|
|
560
|
+
attributesToCrop: ["body"], cropLength: CROP_LENGTH, attributesToHighlight: ["body"], highlightPreTag: HIGHLIGHT_START,
|
|
561
|
+
highlightPostTag: HIGHLIGHT_END, showRankingScore: true,
|
|
562
|
+
};
|
|
563
|
+
const response = await call(conn, "POST", `/indexes/${conn.index}/search`, body);
|
|
564
|
+
// An index that does not exist yet (never written, or dropped) simply holds nothing.
|
|
565
|
+
if (isIndexNotFound(response)) return ok({ hits: [] });
|
|
566
|
+
if (!response.ok) return response.result;
|
|
567
|
+
const hits = response.json?.hits;
|
|
568
|
+
if (!Array.isArray(hits) || !hits.every(isHit)) return unknown("unexpected_response", "Meilisearch returned hits in an unrecognised shape");
|
|
569
|
+
const more = hits.length > page.limit;
|
|
570
|
+
return ok({ hits: hits.slice(0, page.limit).map(toHit), ...(more ? { nextCursor: String(page.offset + page.limit) } : {}) });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------------------------
|
|
574
|
+
// The provider
|
|
575
|
+
// ---------------------------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
/** @type {any} */
|
|
578
|
+
const provider = {
|
|
579
|
+
id: PROVIDER_ID,
|
|
580
|
+
label: "Meilisearch",
|
|
581
|
+
credentialKeys: ["host", "apiKey"],
|
|
582
|
+
capabilities: { acceptsRestricted: false, typoTolerance: true, locales: "any", maxBatch: MAX_BATCH, asyncIndexing: true },
|
|
583
|
+
|
|
584
|
+
/** Create the index (an existing one is fine) and apply settings, then wait for the settings task. @param {SearchProviderContext} ctx */
|
|
585
|
+
async ensureIndex(ctx) {
|
|
586
|
+
const conn = connect(ctx);
|
|
587
|
+
if (isResult(conn)) return conn;
|
|
588
|
+
const created = await call(conn, "POST", "/indexes", { uid: ctx.indexName, primaryKey: "id" });
|
|
589
|
+
if (!created.ok && !failedWith(created, "index_already_exists")) return created.result;
|
|
590
|
+
const task = enqueuedTask(await call(conn, "PATCH", `/indexes/${conn.index}/settings`, INDEX_SETTINGS), "the index settings");
|
|
591
|
+
if (typeof task !== "string") return task;
|
|
592
|
+
// Tasks run in enqueue order, so the settings task settling also means the create task ran
|
|
593
|
+
// (its own `index_already_exists` failure is the idempotent case and needs no check).
|
|
594
|
+
return waitForTask(conn, task, []);
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
/** Delete the index and wait for it to be gone; an absent index is ok. @param {SearchProviderContext} ctx */
|
|
598
|
+
async dropIndex(ctx) {
|
|
599
|
+
const conn = connect(ctx);
|
|
600
|
+
if (isResult(conn)) return conn;
|
|
601
|
+
const response = await call(conn, "DELETE", `/indexes/${conn.index}`);
|
|
602
|
+
if (isIndexNotFound(response)) return ok(undefined);
|
|
603
|
+
const task = enqueuedTask(response, "the index deletion");
|
|
604
|
+
if (typeof task !== "string") return task;
|
|
605
|
+
return waitForTask(conn, task, ["index_not_found"]);
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
/** @param {SearchProviderContext} ctx @param {{ documents: readonly SearchDocument[] }} required */
|
|
609
|
+
upsert(ctx, required) {
|
|
610
|
+
return write(ctx, required.documents, toMeiliDocument);
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
/** @param {SearchProviderContext} ctx @param {{ tombstones: readonly SearchTombstone[] }} required */
|
|
614
|
+
remove(ctx, required) {
|
|
615
|
+
return write(ctx, required.tombstones, toMeiliTombstone);
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
/** @param {SearchProviderContext} ctx @param {{ query: SearchQuery }} required */
|
|
619
|
+
async query(ctx, required) {
|
|
620
|
+
const conn = connect(ctx);
|
|
621
|
+
if (isResult(conn)) return conn;
|
|
622
|
+
const { query } = required;
|
|
623
|
+
if (typeof query.text !== "string" || query.text.trim() === "") return rejected("invalid_request", "query text must not be empty");
|
|
624
|
+
const page = pageOf(query);
|
|
625
|
+
if (typeof page === "string") return rejected("invalid_request", page);
|
|
626
|
+
// `IN []` matches nothing; answering locally avoids relying on how a server version parses it.
|
|
627
|
+
if (query.collections?.length === 0) return ok({ hits: [] });
|
|
628
|
+
return search(conn, query, page);
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
/** @param {SearchProviderContext} ctx @param {{ keys: readonly SearchDocumentKey[] }} required */
|
|
632
|
+
async revisionOf(ctx, required) {
|
|
633
|
+
const conn = connect(ctx);
|
|
634
|
+
if (isResult(conn)) return conn;
|
|
635
|
+
const stored = await fetchRevisions(conn, [...new Set(required.keys.map(documentIdOf))]);
|
|
636
|
+
if (!(stored instanceof Map)) return stored;
|
|
637
|
+
return ok(required.keys.map((key) => {
|
|
638
|
+
const entry = stored.get(documentIdOf(key));
|
|
639
|
+
return { key, revision: entry?.revision ?? null, deleted: entry?.deleted ?? false };
|
|
640
|
+
}));
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
/** @param {SearchProviderContext} ctx @param {{ taskId: string }} required */
|
|
644
|
+
async awaitTask(ctx, required) {
|
|
645
|
+
const conn = connect(ctx);
|
|
646
|
+
if (isResult(conn)) return conn;
|
|
647
|
+
return (await readTask(conn, required.taskId)).result;
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
export default provider;
|