okengine 0.3.6 → 0.4.3

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.
Files changed (89) hide show
  1. package/AGENTS.md +2 -0
  2. package/package.json +12 -12
  3. package/site/content/docs/ai/skills.mdx +5 -3
  4. package/site/content/docs/elements/ai.mdx +2 -0
  5. package/site/content/docs/elements/channel.mdx +2 -0
  6. package/site/content/docs/elements/clock.mdx +1 -4
  7. package/site/content/docs/elements/flow.mdx +4 -10
  8. package/site/content/docs/elements/gate.mdx +2 -0
  9. package/site/content/docs/elements/signal.mdx +1 -5
  10. package/site/content/docs/elements/store.mdx +27 -6
  11. package/site/content/docs/elements/vault.mdx +10 -11
  12. package/site/content/docs/get-started/basic-usage.mdx +76 -41
  13. package/site/content/docs/get-started/installation.mdx +95 -43
  14. package/site/content/docs/get-started/introduction.mdx +128 -75
  15. package/site/content/docs/get-started/meta.json +1 -1
  16. package/site/content/docs/get-started/why.mdx +141 -0
  17. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  18. package/site/content/docs/plugins/security-headers.mdx +1 -1
  19. package/site/content/docs/reference/configuration.mdx +1 -1
  20. package/site/content/docs/reference/environment-variables.mdx +5 -3
  21. package/site/content/docs/reference/fx.mdx +16 -3
  22. package/src/cli/competitor-mention-removal.test.ts +117 -0
  23. package/src/cli/dev.ts +20 -0
  24. package/src/cli/meilisearch-local.test.ts +69 -0
  25. package/src/cli/meilisearch-local.ts +188 -0
  26. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  27. package/src/console/server/store.test.ts +1 -1
  28. package/src/console/server/store.ts +11 -1
  29. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  30. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  31. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  32. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  33. package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
  34. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  35. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  36. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  37. package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
  38. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  39. package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
  40. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  41. package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
  42. package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
  43. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  44. package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
  45. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  46. package/src/console/ui/dist/index.html +2 -2
  47. package/src/docker/compose.ts +5 -0
  48. package/src/docker/docker.test.ts +41 -0
  49. package/src/docker/recipes/index.ts +10 -2
  50. package/src/docker/recipes/meilisearch.ts +31 -0
  51. package/src/drivers/conformance.test.ts +16 -1
  52. package/src/drivers/conformance.ts +40 -3
  53. package/src/drivers/index.ts +14 -2
  54. package/src/drivers/libsql.ts +4 -4
  55. package/src/drivers/meilisearch.integration.test.ts +77 -0
  56. package/src/drivers/meilisearch.test.ts +181 -0
  57. package/src/drivers/meilisearch.ts +208 -0
  58. package/src/drivers/memory.ts +4 -4
  59. package/src/drivers/pgvector.ts +6 -6
  60. package/src/drivers/types.ts +93 -12
  61. package/src/drivers/vault-driver-removal.test.ts +6 -0
  62. package/src/drivers/vault-types.ts +4 -4
  63. package/src/elements/ai/runtime.ts +6 -0
  64. package/src/elements/ai.test.ts +22 -0
  65. package/src/elements/store/index-boot.test.ts +49 -7
  66. package/src/elements/store/runtime.ts +50 -15
  67. package/src/elements/store.ts +2 -0
  68. package/src/elements/vault.test.ts +27 -4
  69. package/src/elements/vault.ts +1 -1
  70. package/src/index.ts +4 -0
  71. package/src/kernel/boot-bind/store.test.ts +9 -0
  72. package/src/kernel/boot-bind/store.ts +30 -2
  73. package/src/kernel/concurrency.test.ts +58 -0
  74. package/src/kernel/concurrency.ts +48 -0
  75. package/src/kernel/fx.test.ts +11 -2
  76. package/src/kernel/fx.ts +37 -5
  77. package/src/kernel/index.ts +10 -1
  78. package/src/kernel/redacted.ts +74 -0
  79. package/src/kernel/router.ts +3 -3
  80. package/src/test/provisions.integration.test.ts +1 -1
  81. package/site/content/docs/get-started/comparison.mdx +0 -65
  82. package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
  83. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  84. package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
  85. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  86. package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
  87. package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
  88. package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
  89. package/src/drivers/vault-infisical.ts +0 -57
@@ -43,6 +43,29 @@ describe("image recipes", () => {
43
43
  expect(redis.match("redis:7")).toBe(true);
44
44
  });
45
45
 
46
+ test("meilisearch matches the official image and emits a http URL", () => {
47
+ expect(recipeFor("getmeili/meilisearch:v1.37").id).toBe("meilisearch");
48
+ const spec: ServiceSpec = {
49
+ role: "store.index",
50
+ serviceName: "store-index",
51
+ image: "getmeili/meilisearch:v1.37",
52
+ port: 7700,
53
+ hostPort: 7700,
54
+ credentials: { user: "oke", password: "meili-master-key", database: "oke" },
55
+ };
56
+ const applied = recipeFor(spec.image).apply(spec);
57
+ expect(applied.environment?.MEILI_MASTER_KEY).toBe("${OKE_STORE_INDEX_KEY}");
58
+ expect(applied.volumes).toContain("store-index-data:/meili_data");
59
+ const url = recipeFor(spec.image).url(spec, {
60
+ host: "127.0.0.1",
61
+ port: 7700,
62
+ user: "oke",
63
+ password: "meili-master-key",
64
+ database: "oke",
65
+ });
66
+ expect(url).toBe("http://127.0.0.1:7700");
67
+ });
68
+
46
69
  test("a new image recipe is ≤15 lines", async () => {
47
70
  const src = await Bun.file(`${import.meta.dir}/recipes/postgres.ts`).text();
48
71
  const exportLines = src
@@ -197,6 +220,24 @@ describe("deriveInfrastructure", () => {
197
220
  expect(formatStackEnv(result.stackEnv)).toContain("OKE_STORE_SQL_PASSWORD=");
198
221
  });
199
222
 
223
+ test("store.index meilisearch emits its own URL + master key env", () => {
224
+ const result = deriveInfrastructure({
225
+ images: { "store.index": "getmeili/meilisearch:v1.37" },
226
+ credentials: {
227
+ "store.index": { user: "oke", password: "meili-master-key", database: "oke" },
228
+ },
229
+ app: "skyport",
230
+ });
231
+ const yml = result.files.find((f) => f.path === "compose.store.index.yml")!.content;
232
+ expect(yml).toContain("getmeili/meilisearch:v1.37");
233
+ expect(yml).toContain("${OKE_STORE_INDEX_KEY}");
234
+ expect(yml).toContain("meili_data");
235
+ expect(yml).not.toContain("meili-master-key");
236
+ expect(result.stackEnv.OKE_STORE_INDEX_URL).toBe("http://127.0.0.1:7700");
237
+ expect(result.stackEnv.OKE_STORE_INDEX_KEY).toBe("meili-master-key");
238
+ expect(result.stackEnv.DATABASE_URL).toBeUndefined();
239
+ });
240
+
200
241
  test("emits protocol-specific env keys plus optional control notes", () => {
201
242
  const result = deriveInfrastructure({
202
243
  images: {
@@ -4,15 +4,23 @@
4
4
 
5
5
  import type { ImageRecipe } from "../types.ts";
6
6
  import { mailpit } from "./mailpit.ts";
7
+ import { meilisearch } from "./meilisearch.ts";
7
8
  import { openbao } from "./openbao.ts";
8
9
  import { postgres } from "./postgres.ts";
9
10
  import { redis } from "./redis.ts";
10
11
  import { rustfs } from "./rustfs.ts";
11
12
 
12
13
  /** Default recipe catalogue. */
13
- export const builtinRecipes: readonly ImageRecipe[] = [postgres, redis, mailpit, rustfs, openbao];
14
+ export const builtinRecipes: readonly ImageRecipe[] = [
15
+ postgres,
16
+ redis,
17
+ mailpit,
18
+ rustfs,
19
+ openbao,
20
+ meilisearch,
21
+ ];
14
22
 
15
- export { mailpit, openbao, postgres, redis, rustfs };
23
+ export { mailpit, meilisearch, openbao, postgres, redis, rustfs };
16
24
 
17
25
  /**
18
26
  * Resolve the recipe for an image reference.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Meilisearch image recipe — full-text search service (`getmeili/meilisearch`).
3
+ *
4
+ * Data lives on a named volume under `/meili_data`; the master key is injected
5
+ * via `${VAR}` (never cleartext in YAML). The official image is Alpine-based
6
+ * and musl-clean — the glibc binary gap only affects raw host installs.
7
+ */
8
+
9
+ import type { ImageRecipe } from "../types.ts";
10
+
11
+ /** Meilisearch FTS service. API on 7700. */
12
+ export const meilisearch: ImageRecipe = {
13
+ id: "meilisearch",
14
+ port: 7700,
15
+ match: (i) => /meilisearch|getmeili/i.test(i),
16
+ apply: (s) => ({
17
+ environment: {
18
+ MEILI_MASTER_KEY: "${OKE_STORE_INDEX_KEY}",
19
+ MEILI_ENV: "${OKE_MEILI_ENV:-production}",
20
+ MEILI_NO_ANALYTICS: "true",
21
+ },
22
+ volumes: [`${s.serviceName}-data:/meili_data`],
23
+ healthcheck: {
24
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:7700/health || exit 1"],
25
+ interval: "5s",
26
+ timeout: "3s",
27
+ retries: 12,
28
+ },
29
+ }),
30
+ url: (_s, c) => `http://${c.host}:${c.port}`,
31
+ };
@@ -9,10 +9,13 @@ import {
9
9
  runIndexConformance,
10
10
  runKvConformance,
11
11
  runSqlConformance,
12
+ runTextIndexConformance,
12
13
  } from "./conformance.ts";
13
14
  import { fsDriver } from "./fs.ts";
14
15
  import { libsqlDriver, libsqlIndexDriver } from "./libsql.ts";
15
16
  import { memoryFilesDriver, memoryIndexDriver, memoryKvDriver, memorySqlDriver } from "./memory.ts";
17
+ import { meilisearchDriver } from "./meilisearch.ts";
18
+ import { createMeilisearchFakeFetch } from "./meilisearch.test.ts";
16
19
  import { pgliteDriver } from "./pglite.ts";
17
20
  import { pgvectorDriver } from "./pgvector.ts";
18
21
  import { createPostgresFakeClient, postgresDriver } from "./postgres.ts";
@@ -24,7 +27,8 @@ describe("sql conformance", () => {
24
27
  test("memory", () => runSqlConformance(memorySqlDriver));
25
28
  test("sqlite", () => runSqlConformance(sqliteDriver, { url: ":memory:" }));
26
29
  test("libsql", () => runSqlConformance(libsqlDriver, { url: ":memory:" }));
27
- test("pglite", () => runSqlConformance(pgliteDriver, { url: "memory://" }));
30
+ // pglite cold-starts WASM + extension load slow disks / CI need > default 5s
31
+ test("pglite", () => runSqlConformance(pgliteDriver, { url: "memory://" }), 15_000);
28
32
  test("postgres (fake Bun.SQL client)", () =>
29
33
  runSqlConformance(postgresDriver, {
30
34
  client: createPostgresFakeClient(),
@@ -53,6 +57,15 @@ describe("index conformance", () => {
53
57
  test("libsql", () => runIndexConformance(libsqlIndexDriver, { dims: 3 }));
54
58
  });
55
59
 
60
+ describe("text index conformance", () => {
61
+ test("meilisearch (injected fetch)", () =>
62
+ runTextIndexConformance(meilisearchDriver, {
63
+ url: "http://127.0.0.1:7700",
64
+ apiKey: "master-key",
65
+ fetch: createMeilisearchFakeFetch(),
66
+ }));
67
+ });
68
+
56
69
  describe("protocol naming", () => {
57
70
  test("driver ids are protocols, never vendors", () => {
58
71
  const ids = [
@@ -69,6 +82,7 @@ describe("protocol naming", () => {
69
82
  memoryIndexDriver.id,
70
83
  pgvectorDriver.id,
71
84
  libsqlIndexDriver.id,
85
+ meilisearchDriver.id,
72
86
  ];
73
87
  expect(ids).toEqual([
74
88
  "memory",
@@ -84,6 +98,7 @@ describe("protocol naming", () => {
84
98
  "memory",
85
99
  "pgvector",
86
100
  "libsql",
101
+ "meilisearch",
87
102
  ]);
88
103
  for (const id of ids) {
89
104
  expect(id).not.toMatch(/neon|dragonfly|minio|upstash|cloudflare/i);
@@ -8,7 +8,14 @@
8
8
  import { expect } from "bun:test";
9
9
  import { createSqlStoreHandle } from "../elements/store/sql-session.ts";
10
10
  import { defineTable } from "../elements/store/table.ts";
11
- import type { FilesDriver, IndexDriver, KvDriver, SqlDriver, SqlConnectOptions } from "./types.ts";
11
+ import type {
12
+ FilesDriver,
13
+ KvDriver,
14
+ SqlDriver,
15
+ SqlConnectOptions,
16
+ TextIndexDriver,
17
+ VectorIndexDriver,
18
+ } from "./types.ts";
12
19
 
13
20
  /** SQL conformance against one driver. */
14
21
  export async function runSqlConformance(
@@ -118,9 +125,9 @@ export async function runFilesConformance(
118
125
  }
119
126
  }
120
127
 
121
- /** Index conformance against one driver. */
128
+ /** Vector index conformance against one driver. */
122
129
  export async function runIndexConformance(
123
- driver: IndexDriver,
130
+ driver: VectorIndexDriver,
124
131
  openOpts: {
125
132
  name?: string;
126
133
  dims?: number;
@@ -147,3 +154,33 @@ export async function runIndexConformance(
147
154
  await index.close();
148
155
  }
149
156
  }
157
+
158
+ /** Full-text index conformance against one driver. */
159
+ export async function runTextIndexConformance(
160
+ driver: TextIndexDriver,
161
+ openOpts: {
162
+ name?: string;
163
+ url?: string;
164
+ apiKey?: string;
165
+ fetch?: typeof fetch;
166
+ } = {},
167
+ ): Promise<void> {
168
+ const index = await driver.open({
169
+ name: openOpts.name ?? "conf-text",
170
+ dims: 0,
171
+ url: openOpts.url,
172
+ apiKey: openOpts.apiKey,
173
+ fetch: openOpts.fetch,
174
+ });
175
+ try {
176
+ await index.upsert("d1", { id: "d1", title: "hello world" });
177
+ await index.upsert("d2", { id: "d2", title: "goodbye world" });
178
+ const found = await index.search("hello", { topK: 1 });
179
+ expect(found.hits.length).toBeGreaterThan(0);
180
+ expect(found.hits[0]?.id).toBe("d1");
181
+ expect(await index.delete("d1")).toBe(true);
182
+ expect(index.driverId).toBe(driver.id);
183
+ } finally {
184
+ await index.close();
185
+ }
186
+ }
@@ -23,12 +23,18 @@ export type {
23
23
  FilesDriver,
24
24
  IndexHit,
25
25
  IndexStore,
26
+ VectorIndexStore,
27
+ TextIndexStore,
28
+ TextIndexSearchOptions,
29
+ TextIndexSearchResult,
26
30
  IndexOpenOptions,
27
31
  IndexDriver,
32
+ VectorIndexDriver,
33
+ TextIndexDriver,
28
34
  StoreDriver,
29
35
  ClassificationMap,
30
36
  } from "./types.ts";
31
- export { classificationKey } from "./types.ts";
37
+ export { classificationKey, indexDriverNeedsSql } from "./types.ts";
32
38
 
33
39
  export type {
34
40
  SignalDriverId,
@@ -79,6 +85,13 @@ export { pgvectorDriver, openPgvectorIndex } from "./pgvector.ts";
79
85
 
80
86
  export { libsqlDriver, connectLibsql, libsqlIndexDriver, openLibsqlIndex } from "./libsql.ts";
81
87
 
88
+ export {
89
+ meilisearchDriver,
90
+ openMeilisearchIndex,
91
+ MeilisearchUnavailableError,
92
+ type MeilisearchIndexOptions,
93
+ } from "./meilisearch.ts";
94
+
82
95
  export { pgliteDriver, connectPglite } from "./pglite.ts";
83
96
 
84
97
  export { memorySignalDriver, openMemorySignal } from "./signal-memory.ts";
@@ -107,7 +120,6 @@ export type { VaultDriverId, VaultOpenOptions, VaultBag, VaultDriver } from "./v
107
120
  export { memoryVaultDriver } from "./vault-memory.ts";
108
121
  export { envVaultDriver } from "./vault-env.ts";
109
122
  export { openbaoVaultDriver, OpenBaoUnavailableError } from "./vault-openbao.ts";
110
- export { infisicalVaultDriver } from "./vault-infisical.ts";
111
123
  export { managedVaultDriver } from "./vault-managed.ts";
112
124
 
113
125
  export type {
@@ -13,14 +13,14 @@
13
13
 
14
14
  import { customType, sqliteTable, text } from "drizzle-orm/sqlite-core";
15
15
  import type {
16
- IndexDriver,
17
16
  IndexHit,
18
17
  IndexOpenOptions,
19
- IndexStore,
20
18
  SqlConnectOptions,
21
19
  SqlConnection,
22
20
  SqlDriver,
23
21
  SqlRow,
22
+ VectorIndexDriver,
23
+ VectorIndexStore,
24
24
  } from "./types.ts";
25
25
 
26
26
  /** `@libsql/client` is an optional peer — loaded only when this driver runs. */
@@ -100,7 +100,7 @@ const f32Blob = customType<{
100
100
  *
101
101
  * @param options - Name / dims / shared SQL connection
102
102
  */
103
- export async function openLibsqlIndex(options: IndexOpenOptions): Promise<IndexStore> {
103
+ export async function openLibsqlIndex(options: IndexOpenOptions): Promise<VectorIndexStore> {
104
104
  const ownsSql = !options.sql;
105
105
  const sql = options.sql ?? (await connectLibsql({ url: options.url }));
106
106
  if (sql.driverId !== "libsql") {
@@ -172,7 +172,7 @@ export async function openLibsqlIndex(options: IndexOpenOptions): Promise<IndexS
172
172
  }
173
173
 
174
174
  /** Protocol-named libsql index driver. */
175
- export const libsqlIndexDriver: IndexDriver = {
175
+ export const libsqlIndexDriver: VectorIndexDriver = {
176
176
  id: "libsql",
177
177
  facet: "index",
178
178
  open: openLibsqlIndex,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Live Meilisearch round-trip — Arabic + typo tolerance.
3
+ *
4
+ * The only acceptable evidence that Meilisearch actually handles Arabic (not
5
+ * just "supported" in marketing copy) is a real round-trip against a live
6
+ * server: index Arabic content, then search it — with and without the
7
+ * definite article (ال), with diacritics stripped, and with a Latin typo that
8
+ * typo-tolerance must forgive.
9
+ *
10
+ * Gated on a live server: set `OKE_TEST_MEILI_URL` (and optionally
11
+ * `OKE_TEST_MEILI_KEY`). Without it the whole suite skips, so unit runs never
12
+ * depend on Docker or a spawned binary.
13
+ */
14
+
15
+ import { afterEach, describe, expect, test } from "bun:test";
16
+ import { openMeilisearchIndex, type MeilisearchIndexOptions } from "./meilisearch.ts";
17
+
18
+ const URL = process.env.OKE_TEST_MEILI_URL;
19
+ const KEY = process.env.OKE_TEST_MEILI_KEY;
20
+ if (!URL) {
21
+ console.log("skip: live meilisearch e2e (OKE_TEST_MEILI_URL not set)");
22
+ }
23
+ const live = URL ? test : test.skip;
24
+
25
+ const opened: Array<{ close(): Promise<void> }> = [];
26
+ afterEach(async () => {
27
+ while (opened.length > 0) {
28
+ await opened.pop()?.close();
29
+ }
30
+ });
31
+
32
+ async function liveIndex(name: string): ReturnType<typeof openMeilisearchIndex> {
33
+ const opts: MeilisearchIndexOptions = { name, dims: 0, url: URL, apiKey: KEY };
34
+ const index = await openMeilisearchIndex(opts);
35
+ opened.push(index);
36
+ return index;
37
+ }
38
+
39
+ describe("meilisearch live — arabic + typo tolerance", () => {
40
+ live("indexes and searches Arabic content (round-trip)", async () => {
41
+ const index = await liveIndex(`ar-${crypto.randomUUID().slice(0, 8)}`);
42
+ await index.upsert("d1", { title: "كتاب عن محركات البحث" });
43
+ await index.upsert("d2", { title: "مقال عن قواعد البيانات" });
44
+
45
+ const found = await index.search("البحث", { topK: 5 });
46
+ expect(found.hits.length).toBeGreaterThan(0);
47
+ expect(found.hits[0]?.id).toBe("d1");
48
+ });
49
+
50
+ live("matches a query without the definite article ال", async () => {
51
+ const index = await liveIndex(`ar-${crypto.randomUUID().slice(0, 8)}`);
52
+ await index.upsert("d1", { title: "الكتاب" });
53
+ // Charabia segments/normalises ال, so the bare stem should still match.
54
+ const found = await index.search("كتاب", { topK: 5 });
55
+ expect(found.hits.map((h) => h.id)).toContain("d1");
56
+ });
57
+
58
+ live("typo tolerance forgives a latin misspelling", async () => {
59
+ const index = await liveIndex(`en-${crypto.randomUUID().slice(0, 8)}`);
60
+ await index.upsert("d1", { title: "search engine" });
61
+ // One-transposition typo ("serach") must still hit the document.
62
+ const found = await index.search("serach", { topK: 5 });
63
+ expect(found.hits.map((h) => h.id)).toContain("d1");
64
+ });
65
+
66
+ live("filter + facets aggregate real results", async () => {
67
+ const index = await liveIndex(`fac-${crypto.randomUUID().slice(0, 8)}`);
68
+ await index.upsert("d1", { title: "red apple", color: "red" });
69
+ await index.upsert("d2", { title: "green apple", color: "green" });
70
+ const found = await index.search("apple", {
71
+ topK: 5,
72
+ filter: "color = red",
73
+ facets: ["color"],
74
+ });
75
+ expect(found.hits.map((h) => h.id)).toEqual(["d1"]);
76
+ });
77
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `meilisearch` index driver — task-waited writes, relevance search mapping,
3
+ * and fail-loud semantics against an injected fetch (never a live server).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ MeilisearchUnavailableError,
9
+ meilisearchDriver,
10
+ openMeilisearchIndex,
11
+ } from "./meilisearch.ts";
12
+
13
+ /** A scripted Meilisearch over the wire — in-memory task + document store. */
14
+ export function createMeilisearchFakeFetch(opts?: {
15
+ readonly failHealth?: boolean;
16
+ readonly failTask?: boolean;
17
+ }): typeof fetch {
18
+ const documents = new Map<string, Record<string, unknown>>();
19
+ const taskResults = new Map<number, { fail: boolean }>();
20
+ let taskSeq = 0;
21
+
22
+ const json = (body: unknown, status = 200): Response =>
23
+ new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: { "content-type": "application/json" },
26
+ });
27
+
28
+ const task = (type: string, fail: boolean): object => {
29
+ taskSeq += 1;
30
+ taskResults.set(taskSeq, { fail });
31
+ return { taskUid: taskSeq, indexUid: "kb", status: "enqueued", type };
32
+ };
33
+
34
+ return (async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
35
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
36
+ const method = init?.method ?? "GET";
37
+
38
+ if (url.endsWith("/health")) {
39
+ if (opts?.failHealth) return json({ message: "down" }, 503);
40
+ return json({ status: "available" });
41
+ }
42
+
43
+ if (url.endsWith("/indexes") && method === "POST") {
44
+ return json(task("indexCreation", false), 202);
45
+ }
46
+
47
+ if (url.includes("/tasks/")) {
48
+ const taskUid = Number(url.split("/tasks/")[1]);
49
+ const fail = taskResults.get(taskUid)?.fail ?? false;
50
+ return json({
51
+ taskUid,
52
+ status: fail ? "failed" : "succeeded",
53
+ error: fail ? { message: "boom" } : null,
54
+ });
55
+ }
56
+
57
+ if (url.includes("/documents/") && method === "DELETE") {
58
+ const id = decodeURIComponent(url.split("/documents/")[1] ?? "");
59
+ if (!documents.delete(id)) return json({ message: "not found" }, 404);
60
+ return json(task("documentDeletion", opts?.failTask ?? false), 202);
61
+ }
62
+
63
+ if (url.endsWith("/documents") && method === "POST") {
64
+ const docs = JSON.parse(String(init?.body ?? "[]")) as Record<string, unknown>[];
65
+ if (!(opts?.failTask ?? false)) {
66
+ for (const doc of docs) documents.set(String(doc.id), doc);
67
+ }
68
+ return json(task("documentAdditionOrUpdate", opts?.failTask ?? false), 202);
69
+ }
70
+
71
+ if (url.endsWith("/search") && method === "POST") {
72
+ const {
73
+ q,
74
+ limit = 10,
75
+ facets,
76
+ } = JSON.parse(String(init?.body ?? "{}")) as {
77
+ q?: string;
78
+ limit?: number;
79
+ facets?: string[];
80
+ };
81
+ const query = (q ?? "").toLowerCase();
82
+ const hits = [...documents.values()]
83
+ .filter((doc) => query === "" || JSON.stringify(doc).toLowerCase().includes(query))
84
+ .slice(0, limit)
85
+ .map((doc) => ({ ...doc, _rankingScore: 0.95 }));
86
+ const facetDistribution: Record<string, Record<string, number>> = {};
87
+ for (const facet of facets ?? []) {
88
+ facetDistribution[facet] = { tagged: documents.size };
89
+ }
90
+ return json({
91
+ hits,
92
+ ...(facets && facets.length > 0 ? { facetDistribution } : {}),
93
+ estimatedTotalHits: hits.length,
94
+ });
95
+ }
96
+
97
+ return json({ message: `no route for ${method} ${url}` }, 404);
98
+ }) as typeof fetch;
99
+ }
100
+
101
+ describe("meilisearch driver", () => {
102
+ test("upsert → search maps _rankingScore into relevance score", async () => {
103
+ const index = await openMeilisearchIndex({
104
+ name: "kb",
105
+ dims: 0,
106
+ url: "http://127.0.0.1:7700",
107
+ apiKey: "master-key",
108
+ fetch: createMeilisearchFakeFetch(),
109
+ });
110
+ await index.upsert("d1", { title: "hello world", tag: "a" });
111
+ await index.upsert("d2", { title: "goodbye world", tag: "b" });
112
+
113
+ const found = await index.search("hello", { topK: 1 });
114
+ expect(found.hits).toHaveLength(1);
115
+ expect(found.hits[0]?.id).toBe("d1");
116
+ expect(found.hits[0]?.score).toBeGreaterThan(0);
117
+ expect(found.hits[0]?.meta).toMatchObject({ title: "hello world", tag: "a" });
118
+ await index.close();
119
+ });
120
+
121
+ test("facets return a facetDistribution", async () => {
122
+ const index = await openMeilisearchIndex({
123
+ name: "kb",
124
+ dims: 0,
125
+ url: "http://127.0.0.1:7700",
126
+ fetch: createMeilisearchFakeFetch(),
127
+ });
128
+ await index.upsert("d1", { title: "x", tag: "a" });
129
+ const found = await index.search("x", { facets: ["tag"] });
130
+ expect(found.facetDistribution).toEqual({ tag: { tagged: 1 } });
131
+ await index.close();
132
+ });
133
+
134
+ test("delete removes the document; missing id is false", async () => {
135
+ const index = await openMeilisearchIndex({
136
+ name: "kb",
137
+ dims: 0,
138
+ url: "http://127.0.0.1:7700",
139
+ fetch: createMeilisearchFakeFetch(),
140
+ });
141
+ await index.upsert("d1", { title: "x" });
142
+ expect(await index.delete("d1")).toBe(true);
143
+ expect(await index.delete("d1")).toBe(false);
144
+ await index.close();
145
+ });
146
+
147
+ test("missing URL fails loud (no memory fallback)", async () => {
148
+ await expect(openMeilisearchIndex({ name: "kb", dims: 0 })).rejects.toBeInstanceOf(
149
+ MeilisearchUnavailableError,
150
+ );
151
+ });
152
+
153
+ test("unhealthy server fails loud at open", async () => {
154
+ await expect(
155
+ openMeilisearchIndex({
156
+ name: "kb",
157
+ dims: 0,
158
+ url: "http://127.0.0.1:7700",
159
+ fetch: createMeilisearchFakeFetch({ failHealth: true }),
160
+ }),
161
+ ).rejects.toBeInstanceOf(MeilisearchUnavailableError);
162
+ });
163
+
164
+ test("a failed indexing task surfaces as a loud error, not a silent write", async () => {
165
+ const index = await openMeilisearchIndex({
166
+ name: "kb",
167
+ dims: 0,
168
+ url: "http://127.0.0.1:7700",
169
+ fetch: createMeilisearchFakeFetch({ failTask: true }),
170
+ });
171
+ await expect(index.upsert("d1", { title: "x" })).rejects.toBeInstanceOf(
172
+ MeilisearchUnavailableError,
173
+ );
174
+ await index.close();
175
+ });
176
+
177
+ test("driver id + facet", () => {
178
+ expect(meilisearchDriver.id).toBe("meilisearch");
179
+ expect(meilisearchDriver.facet).toBe("index");
180
+ });
181
+ });