okengine 0.12.0 → 0.14.1

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 (127) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +21 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/providers/index.mdx +1 -0
  11. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  12. package/site/content/docs/recipes/redis.mdx +4 -5
  13. package/site/content/docs/recipes/valkey.mdx +5 -3
  14. package/site/content/docs/reference/configuration.mdx +3 -1
  15. package/site/content/docs/reference/environment-variables.mdx +5 -5
  16. package/site/content/docs/reference/fx.mdx +27 -22
  17. package/src/cli/ai-setup/recommend.test.ts +25 -0
  18. package/src/cli/ai-setup/recommend.ts +8 -3
  19. package/src/cli/load-config.images.test.ts +1 -0
  20. package/src/compiler/effects-infer.ts +58 -3
  21. package/src/compiler/extract.test.ts +68 -1
  22. package/src/compiler/extract.ts +70 -2
  23. package/src/compiler/response.ts +45 -1
  24. package/src/console/server/ai.ts +5 -2
  25. package/src/console/server/flows.ts +1 -0
  26. package/src/console/server/serve.ts +17 -2
  27. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  28. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  29. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  30. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
  31. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  32. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  33. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  34. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
  35. package/src/console/ui-next/dist/index.html +1 -1
  36. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  37. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  38. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  39. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  40. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  41. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  42. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  43. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  44. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  45. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  46. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  47. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  48. package/src/docker/docker.test.ts +1 -1
  49. package/src/docker/dockerfile.ts +1 -1
  50. package/src/docker/helpers.ts +28 -0
  51. package/src/docker/recipes/dragonfly.ts +3 -6
  52. package/src/docker/recipes/redis.ts +2 -6
  53. package/src/docker/recipes/valkey.ts +2 -6
  54. package/src/drivers/ai-anthropic.ts +5 -0
  55. package/src/drivers/ai-ollama.ts +49 -30
  56. package/src/drivers/ai-openai-compatible.ts +57 -46
  57. package/src/drivers/ai-providers.test.ts +3 -0
  58. package/src/drivers/bun-native-completeness.test.ts +7 -9
  59. package/src/drivers/redis.ts +11 -4
  60. package/src/drivers/signal-redis.ts +24 -14
  61. package/src/drivers/signal-types.ts +2 -1
  62. package/src/drivers/types.ts +1 -1
  63. package/src/elements/ai/declare.ts +109 -0
  64. package/src/elements/ai/errors.test.ts +5 -1
  65. package/src/elements/ai/errors.ts +30 -2
  66. package/src/elements/ai/eval.ts +4 -6
  67. package/src/elements/ai/mcp-client.test.ts +206 -0
  68. package/src/elements/ai/mcp-client.ts +362 -0
  69. package/src/elements/ai/mcp-http.ts +159 -0
  70. package/src/elements/ai/mcp-mock.ts +134 -0
  71. package/src/elements/ai/mcp-protocol.ts +234 -0
  72. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  73. package/src/elements/ai/mcp-stdio.ts +212 -0
  74. package/src/elements/ai/mcp-transport.ts +70 -0
  75. package/src/elements/ai/runtime.ts +159 -29
  76. package/src/elements/ai.test.ts +139 -0
  77. package/src/elements/ai.ts +16 -0
  78. package/src/elements/clock/health.test.ts +43 -0
  79. package/src/elements/clock/runtime.ts +55 -2
  80. package/src/elements/clock/schedule.ts +71 -142
  81. package/src/elements/clock.test.ts +9 -40
  82. package/src/elements/clock.ts +6 -1
  83. package/src/elements/gate/runtime.ts +3 -3
  84. package/src/elements/index.ts +2 -0
  85. package/src/elements/signal/declare.ts +2 -1
  86. package/src/elements/signal/runtime.ts +16 -1
  87. package/src/elements/signal.ts +1 -0
  88. package/src/elements/store/cache.test.ts +2 -0
  89. package/src/elements/store/cache.ts +3 -3
  90. package/src/elements/store/declare.ts +7 -0
  91. package/src/elements/store/kv-sql.test.ts +86 -0
  92. package/src/elements/store/kv-sql.ts +178 -0
  93. package/src/elements/store/runtime.ts +35 -9
  94. package/src/elements/store.test.ts +2 -0
  95. package/src/elements/vault/builtin-adapter.ts +17 -0
  96. package/src/full.ts +2 -0
  97. package/src/index.ts +4 -0
  98. package/src/kernel/app.ts +97 -64
  99. package/src/kernel/auto-registry.test.ts +5 -0
  100. package/src/kernel/boot-bind/ai.ts +24 -0
  101. package/src/kernel/boot-bind/clock.ts +2 -0
  102. package/src/kernel/boot-bind/store.test.ts +150 -1
  103. package/src/kernel/boot-bind/store.ts +66 -1
  104. package/src/kernel/boot.ts +3 -1
  105. package/src/kernel/element-registries.ts +4 -1
  106. package/src/kernel/fx-dead-letters.test.ts +77 -0
  107. package/src/kernel/fx.test.ts +22 -0
  108. package/src/kernel/fx.ts +112 -6
  109. package/src/kernel/http-stream.test.ts +174 -0
  110. package/src/kernel/index.ts +2 -0
  111. package/src/manifest/diff.test.ts +13 -0
  112. package/src/manifest/diff.ts +15 -0
  113. package/src/manifest/mcp-ref.ts +88 -0
  114. package/src/manifest/types.ts +33 -2
  115. package/src/manifest/validate.test.ts +20 -0
  116. package/src/mcp/docs-server.ts +1 -1
  117. package/src/mcp/server.ts +1 -1
  118. package/src/plugins/compression.test.ts +21 -0
  119. package/src/plugins/compression.ts +1 -0
  120. package/src/runtime/bun.ts +41 -4
  121. package/src/test/reset-element-registries.ts +2 -0
  122. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  123. package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
  124. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  125. package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
  126. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  127. package/src/console/ui-next/dist/assets/units-page-C5KOI7qG.js +0 -1
@@ -5,6 +5,7 @@
5
5
  * Official image: `docker.dragonflydb.io/dragonflydb/dragonfly`.
6
6
  */
7
7
 
8
+ import { dragonflyServerCommand } from "../helpers.ts";
8
9
  import type { ImageRecipe } from "../types.ts";
9
10
 
10
11
  /** Dragonfly — Redis protocol on 6379. */
@@ -12,13 +13,9 @@ export const dragonfly: ImageRecipe = {
12
13
  id: "dragonfly",
13
14
  port: 6379,
14
15
  match: (i) => /dragonfly/i.test(i),
15
- apply: () => ({
16
+ apply: (s) => ({
16
17
  environment: { HEALTHCHECK_PORT: "6379" },
17
- command: [
18
- "sh",
19
- "-c",
20
- 'exec dragonfly --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}"',
21
- ],
18
+ command: ["sh", "-c", dragonflyServerCommand(s)],
22
19
  ulimits: { memlock: -1 },
23
20
  healthcheck: {
24
21
  test: ["CMD", "/usr/local/bin/healthcheck.sh"],
@@ -5,7 +5,7 @@
5
5
  * `redis://` URL, different binaries. Pin via `images["store.kv"]`.
6
6
  */
7
7
 
8
- import { credEnv } from "../helpers.ts";
8
+ import { credEnv, kvServerCommand } from "../helpers.ts";
9
9
  import type { ImageRecipe } from "../types.ts";
10
10
 
11
11
  /** Redis Open Source — Redis protocol on 6379. */
@@ -14,11 +14,7 @@ export const redis: ImageRecipe = {
14
14
  port: 6379,
15
15
  match: (i) => /(?:^|\/)redis(?:[:@/]|$)/i.test(i) || /keydb/i.test(i),
16
16
  apply: (s) => ({
17
- command: [
18
- "sh",
19
- "-c",
20
- 'exec redis-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
21
- ],
17
+ command: ["sh", "-c", kvServerCommand(s, "redis-server")],
22
18
  healthcheck: {
23
19
  test: ["CMD", "redis-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
24
20
  interval: "5s",
@@ -5,7 +5,7 @@
5
5
  * `valkey/valkey`.
6
6
  */
7
7
 
8
- import { credEnv } from "../helpers.ts";
8
+ import { credEnv, kvServerCommand } from "../helpers.ts";
9
9
  import type { ImageRecipe } from "../types.ts";
10
10
 
11
11
  /** Valkey — Redis protocol on 6379. */
@@ -14,11 +14,7 @@ export const valkey: ImageRecipe = {
14
14
  port: 6379,
15
15
  match: (i) => /valkey/i.test(i),
16
16
  apply: (s) => ({
17
- command: [
18
- "sh",
19
- "-c",
20
- 'exec valkey-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
21
- ],
17
+ command: ["sh", "-c", kvServerCommand(s, "valkey-server")],
22
18
  healthcheck: {
23
19
  test: ["CMD", "valkey-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
24
20
  interval: "5s",
@@ -29,6 +29,10 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
29
29
  const model = options.model ?? "claude-sonnet-4-20250514";
30
30
  const baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
31
31
  const fetchFn = options.fetch ?? globalThis.fetch;
32
+ const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
33
+ if (typeof preconnect === "function") {
34
+ preconnect(baseUrl);
35
+ }
32
36
 
33
37
  return {
34
38
  driverId: "anthropic",
@@ -50,6 +54,7 @@ export async function openAnthropic(options: AiOpenOptions = {}): Promise<AiMode
50
54
  ...(system !== undefined ? { system } : {}),
51
55
  messages,
52
56
  }),
57
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
53
58
  });
54
59
  const raw = (await res.json().catch(() => ({}))) as AnthropicMessagesResponse;
55
60
  if (!res.ok) {
@@ -156,10 +156,7 @@ export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelCl
156
156
  const msg = raw.error ?? `ollama HTTP ${res.status}`;
157
157
  throw new OllamaUnavailableError(`ollama: ${msg}`);
158
158
  }
159
- if (!res.body) {
160
- throw new OllamaUnavailableError("ollama: stream response has no body");
161
- }
162
- yield* readOllamaNdjson(res.body, opts.signal);
159
+ yield* readOllamaNdjson(res, opts.signal);
163
160
  },
164
161
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
165
162
  const resolvedModel = resolveOllamaModel(options, opts.model);
@@ -265,45 +262,45 @@ function parseOllamaToolCalls(
265
262
  }
266
263
 
267
264
  /**
268
- * Parse Ollama NDJSON stream lines.
265
+ * Parse Ollama NDJSON stream lines via {@link Bun.JSONL.parseChunk}.
269
266
  *
270
- * @param body - Response body
267
+ * @param res - Streaming response
271
268
  * @param signal - Optional abort
272
269
  */
273
270
  async function* readOllamaNdjson(
274
- body: ReadableStream<Uint8Array>,
271
+ res: Response,
275
272
  signal?: AbortSignal,
276
273
  ): AsyncGenerator<AiStreamChunk> {
277
- const reader = body.getReader();
274
+ const reader = res.body?.getReader();
275
+ if (!reader) {
276
+ throw new OllamaUnavailableError("ollama: stream response has no body");
277
+ }
278
278
  const decoder = new TextDecoder();
279
- let buffer = "";
279
+ let pending = "";
280
280
  try {
281
281
  while (true) {
282
- if (signal?.aborted) {
283
- throw abortAsError(signal.reason);
284
- }
282
+ if (signal?.aborted) throw abortAsError(signal.reason);
285
283
  const { done, value } = await reader.read();
286
284
  if (done) break;
287
- buffer += decoder.decode(value, { stream: true });
288
- const lines = buffer.split("\n");
289
- buffer = lines.pop() ?? "";
290
- for (const line of lines) {
291
- const trimmed = line.trim();
292
- if (!trimmed) continue;
293
- try {
294
- const chunk = JSON.parse(trimmed) as OllamaChatResponse;
295
- const delta = chunk.message?.content;
296
- if (typeof delta === "string" && delta.length > 0) {
297
- yield { text: delta };
298
- }
299
- if (chunk.done) {
300
- yield { text: "", done: true };
301
- return;
302
- }
303
- } catch {
304
- // ignore malformed lines
285
+ pending += decoder.decode(value, { stream: true });
286
+ const parsed = bunJsonl().parseChunk(pending);
287
+ pending = pending.slice(parsed.read);
288
+ for (const row of parsed.values) {
289
+ const chunk = row as OllamaChatResponse;
290
+ const delta = chunk.message?.content;
291
+ if (typeof delta === "string" && delta.length > 0) {
292
+ yield { text: delta };
293
+ }
294
+ if (chunk.done) {
295
+ yield { text: "", done: true };
296
+ return;
305
297
  }
306
298
  }
299
+ if (parsed.error) {
300
+ const nl = pending.indexOf("\n");
301
+ if (nl === -1) continue;
302
+ pending = pending.slice(nl + 1);
303
+ }
307
304
  }
308
305
  yield { text: "", done: true };
309
306
  } finally {
@@ -311,6 +308,28 @@ async function* readOllamaNdjson(
311
308
  }
312
309
  }
313
310
 
311
+ interface BunJsonlParse {
312
+ readonly values: readonly unknown[];
313
+ readonly read: number;
314
+ readonly done: boolean;
315
+ readonly error: unknown;
316
+ }
317
+
318
+ function bunJsonl(): {
319
+ parse(text: string): unknown[];
320
+ parseChunk(text: string): BunJsonlParse;
321
+ } {
322
+ const jsonl = (
323
+ Bun as typeof Bun & {
324
+ JSONL?: { parse: (t: string) => unknown[]; parseChunk: (t: string) => BunJsonlParse };
325
+ }
326
+ ).JSONL;
327
+ if (!jsonl) {
328
+ throw new Error("ollama: Bun.JSONL is required (Bun >= 1.4.0)");
329
+ }
330
+ return jsonl;
331
+ }
332
+
314
333
  /**
315
334
  * Probe Ollama — fail loud before the first completion.
316
335
  *
@@ -89,6 +89,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
89
89
  const model = options.model ?? "gpt-4o-mini";
90
90
  const fetchFn = options.fetch ?? globalThis.fetch;
91
91
  const extraHeaders = options.headers;
92
+ preconnectFetch(fetchFn, baseUrl);
92
93
 
93
94
  return {
94
95
  driverId: "openai-compatible",
@@ -162,10 +163,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
162
163
  const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
163
164
  throwHttp(`openai-compatible: ${msg}`, res.status);
164
165
  }
165
- if (!res.body) {
166
- throw new Error("openai-compatible: stream response has no body");
167
- }
168
- yield* readOpenaiSse(res.body, opts.signal);
166
+ yield* readOpenaiSse(res, opts.signal);
169
167
  },
170
168
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
171
169
  const resolvedModel = opts.model ?? model;
@@ -262,57 +260,70 @@ function parseToolCalls(
262
260
  /**
263
261
  * Parse OpenAI SSE chat.completion.chunk stream.
264
262
  *
265
- * @param body - Response body
263
+ * @param res - Streaming response
266
264
  * @param signal - Optional abort
267
265
  */
268
- async function* readOpenaiSse(
269
- body: ReadableStream<Uint8Array>,
270
- signal?: AbortSignal,
271
- ): AsyncGenerator<AiStreamChunk> {
272
- const reader = body.getReader();
273
- const decoder = new TextDecoder();
266
+ async function* readOpenaiSse(res: Response, signal?: AbortSignal): AsyncGenerator<AiStreamChunk> {
274
267
  let buffer = "";
275
- try {
276
- while (true) {
277
- if (signal?.aborted) {
278
- throw abortAsError(signal.reason);
268
+ for await (const piece of responseTextStream(res)) {
269
+ if (signal?.aborted) throw abortAsError(signal.reason);
270
+ buffer += piece;
271
+ const lines = buffer.split("\n");
272
+ buffer = lines.pop() ?? "";
273
+ for (const line of lines) {
274
+ const trimmed = line.trim();
275
+ if (!trimmed.startsWith("data:")) continue;
276
+ const data = trimmed.slice(5).trim();
277
+ if (data === "[DONE]") {
278
+ yield { text: "", done: true };
279
+ return;
279
280
  }
280
- const { done, value } = await reader.read();
281
- if (done) break;
282
- buffer += decoder.decode(value, { stream: true });
283
- const lines = buffer.split("\n");
284
- buffer = lines.pop() ?? "";
285
- for (const line of lines) {
286
- const trimmed = line.trim();
287
- if (!trimmed.startsWith("data:")) continue;
288
- const data = trimmed.slice(5).trim();
289
- if (data === "[DONE]") {
281
+ try {
282
+ const chunk = JSON.parse(data) as {
283
+ choices?: readonly {
284
+ delta?: { content?: string | null };
285
+ finish_reason?: string | null;
286
+ }[];
287
+ };
288
+ const delta = chunk.choices?.[0]?.delta?.content;
289
+ if (typeof delta === "string" && delta.length > 0) {
290
+ yield { text: delta };
291
+ }
292
+ if (chunk.choices?.[0]?.finish_reason) {
290
293
  yield { text: "", done: true };
291
294
  return;
292
295
  }
293
- try {
294
- const chunk = JSON.parse(data) as {
295
- choices?: readonly {
296
- delta?: { content?: string | null };
297
- finish_reason?: string | null;
298
- }[];
299
- };
300
- const delta = chunk.choices?.[0]?.delta?.content;
301
- if (typeof delta === "string" && delta.length > 0) {
302
- yield { text: delta };
303
- }
304
- if (chunk.choices?.[0]?.finish_reason) {
305
- yield { text: "", done: true };
306
- return;
307
- }
308
- } catch {
309
- // ignore malformed SSE lines
310
- }
296
+ } catch {
297
+ // ignore malformed SSE lines
311
298
  }
312
299
  }
313
- yield { text: "", done: true };
314
- } finally {
315
- reader.releaseLock();
300
+ }
301
+ yield { text: "", done: true };
302
+ }
303
+
304
+ /**
305
+ * UTF-8 text chunks from a Response (`textStream` on Bun ≥1.4).
306
+ *
307
+ * @param res - HTTP response
308
+ */
309
+ function responseTextStream(res: Response): AsyncIterable<string> {
310
+ const stream = (res as Response & { textStream?: () => AsyncIterable<string> }).textStream;
311
+ if (typeof stream !== "function") {
312
+ throw new Error("openai-compatible: Response.textStream is required (Bun >= 1.4.0)");
313
+ }
314
+ return stream.call(res);
315
+ }
316
+
317
+ /**
318
+ * Warm DNS+TCP+TLS for a cloud origin. No-op when `fetch` is a test stub.
319
+ *
320
+ * @param fetchFn - Fetch implementation
321
+ * @param url - Provider base URL
322
+ */
323
+ function preconnectFetch(fetchFn: typeof fetch, url: string): void {
324
+ const preconnect = (fetchFn as { preconnect?: (href: string) => void }).preconnect;
325
+ if (typeof preconnect === "function") {
326
+ preconnect(url);
316
327
  }
317
328
  }
318
329
 
@@ -43,12 +43,14 @@ describe("anthropic driver", () => {
43
43
  model: "claude-test",
44
44
  fetch: fetchFn,
45
45
  });
46
+ const ac = new AbortController();
46
47
  const result = await client.complete({
47
48
  messages: [
48
49
  { role: "system", content: "be brief" },
49
50
  { role: "user", content: "hi" },
50
51
  ],
51
52
  maxTokens: 64,
53
+ signal: ac.signal,
52
54
  });
53
55
 
54
56
  expect(result.driverId).toBe("anthropic");
@@ -62,6 +64,7 @@ describe("anthropic driver", () => {
62
64
  };
63
65
  expect(body.system).toBe("be brief");
64
66
  expect(body.messages).toEqual([{ role: "user", content: "hi" } as { role: string }]);
67
+ expect(calls[0]!.init?.signal).toBe(ac.signal);
65
68
  });
66
69
 
67
70
  test("HTTP error surfaces provider message", async () => {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Bun native-client completeness — gaps closed vs platform limitations.
3
3
  *
4
- * Checked against Bun 1.3.14 (runtime + bun-types). Do not invent custom
5
- * protocol clients where Bun still lacks an API.
4
+ * Checked against Bun 1.4 (runtime). Do not invent custom protocol clients
5
+ * where Bun still lacks an API.
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
@@ -46,7 +46,7 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
46
46
  status: hasTypedEval ? "closed" : "bun_limitation",
47
47
  note: hasTypedEval
48
48
  ? "typed eval bound"
49
- : "Bun 1.3.14 has no typed eval driver uses send(EVAL)",
49
+ : "Bun.RedisClient.eval unavailablewould keep send(EVAL)",
50
50
  },
51
51
  {
52
52
  surface: "redis signal",
@@ -63,7 +63,7 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
63
63
  status: hasTypedXadd ? "closed" : "bun_limitation",
64
64
  note: hasTypedXadd
65
65
  ? "typed stream helpers bound"
66
- : "Bun 1.3.14 has no typed xadd — Streams use send (native, not a custom client)",
66
+ : "Bun.RedisClient.xadd unavailable — Streams would keep send",
67
67
  },
68
68
  {
69
69
  surface: "redis signal",
@@ -95,23 +95,21 @@ export function bunNativeCompletenessReport(): readonly BunNativeGapRow[] {
95
95
  }
96
96
 
97
97
  describe("Bun native client completeness", () => {
98
- test("report table is exact and matches runtime Bun 1.3.x", () => {
98
+ test("report table is exact and matches runtime Bun 1.4", () => {
99
99
  const rows = bunNativeCompletenessReport();
100
100
  expect(rows.length).toBe(8);
101
101
 
102
102
  const closed = rows.filter((r) => r.status === "closed").map((r) => r.capability);
103
103
  const limited = rows.filter((r) => r.status === "bun_limitation").map((r) => r.capability);
104
104
 
105
- // Closed on Bun 1.3.14
106
105
  expect(closed).toContain("SCAN (typed)");
106
+ expect(closed).toContain("EVAL (typed)");
107
107
  expect(closed).toContain("native Bun bind (publish/subscribe)");
108
+ expect(closed).toContain("XADD / XREADGROUP (typed)");
108
109
  expect(closed).toContain("send escape hatch");
109
110
  expect(closed).toContain("Bun.SQL.unsafe query/exec");
110
111
  expect(closed).toContain("Bun.S3Client file/list");
111
112
 
112
- // Genuine Bun platform limitations (do not invent workarounds)
113
- expect(limited).toContain("EVAL (typed)");
114
- expect(limited).toContain("XADD / XREADGROUP (typed)");
115
113
  expect(limited).toContain("LISTEN / NOTIFY on Bun.SQL");
116
114
  });
117
115
 
@@ -140,11 +140,19 @@ function ttlToSeconds(ttl: string): number {
140
140
  }
141
141
  }
142
142
 
143
+ /** Bun 1.4 typed methods — `@types/bun` may lag the runtime. */
144
+ interface BunRedisEval {
145
+ eval(script: string, numkeys: number, ...keysAndArgs: string[]): Promise<unknown>;
146
+ }
147
+
148
+ function typedRedis(redis: Bun.RedisClient): Bun.RedisClient & BunRedisEval {
149
+ return redis as Bun.RedisClient & BunRedisEval;
150
+ }
151
+
143
152
  /**
144
153
  * Bind {@link Bun.RedisClient} / {@link Bun.redis} to {@link KvClientLike}.
145
154
  *
146
- * Uses typed `scan` (Bun ≥1.3). `EVAL` still goes through `send` — Bun 1.3.14
147
- * has no typed `eval` yet (platform limitation, not an OKE stub).
155
+ * Uses typed `scan` / `eval` (Bun ≥1.4). `send` remains the escape hatch.
148
156
  *
149
157
  * @param url - Optional Redis URL
150
158
  */
@@ -174,8 +182,7 @@ export function createBunRedisClient(url?: string): KvClientLike {
174
182
  return redis.scan(cursor);
175
183
  },
176
184
  async eval(script, numkeys, ...keysAndArgs) {
177
- // Bun 1.3.14: no typed eval raw send remains the native path.
178
- return redis.send("EVAL", [script, String(numkeys), ...keysAndArgs]);
185
+ return typedRedis(redis).eval(script, numkeys, ...keysAndArgs);
179
186
  },
180
187
  send: (command, args) => redis.send(command, args),
181
188
  };
@@ -7,7 +7,7 @@
7
7
  * exactly-once / fan-out physics stay correct under `drain()`.
8
8
  *
9
9
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
10
- * `subscribe`; Streams via `send` Bun 1.3.14 has no typed `xadd` yet).
10
+ * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
11
11
  */
12
12
 
13
13
  import { createSignalEngine } from "./signal-engine.ts";
@@ -18,12 +18,23 @@ import type {
18
18
  SignalRedisClientLike,
19
19
  } from "./signal-types.ts";
20
20
 
21
+ /** Bun 1.4 typed stream methods — `@types/bun` may lag the runtime. */
22
+ interface BunRedisStreams {
23
+ xadd(key: string, ...args: (string | number)[]): Promise<unknown>;
24
+ xreadgroup(...args: (string | number)[]): Promise<unknown>;
25
+ xack(key: string, group: string, id: string): Promise<unknown>;
26
+ xgroup(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
27
+ }
28
+
29
+ function typedStreams(redis: Bun.RedisClient): Bun.RedisClient & BunRedisStreams {
30
+ return redis as Bun.RedisClient & BunRedisStreams;
31
+ }
32
+
21
33
  /**
22
34
  * Bind {@link Bun.RedisClient} to {@link SignalRedisClientLike}.
23
35
  *
24
- * Streams (`XADD` / `XGROUP` / `XREADGROUP` / `XACK`) use `send` because
25
- * Bun 1.3.14 still lacks typed stream helpers — that is a Bun limitation.
26
- * Pub/sub uses typed `publish` / `subscribe` (gap closed vs earlier fake-only).
36
+ * Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xack` (Bun ≥1.4).
37
+ * Pub/sub uses typed `publish` / `subscribe`.
27
38
  *
28
39
  * @param url - Optional Redis URL
29
40
  */
@@ -41,37 +52,36 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
41
52
 
42
53
  return {
43
54
  async xadd(key, id, fields) {
44
- const args: string[] = [key, id];
55
+ const pairs: string[] = [];
45
56
  for (const [k, v] of Object.entries(fields)) {
46
- args.push(k, v);
57
+ pairs.push(k, v);
47
58
  }
48
- return String(await redis.send("XADD", args));
59
+ return String(await typedStreams(redis).xadd(key, id, ...pairs));
49
60
  },
50
61
  async xgroupCreate(key, group, id, opts) {
51
- const args = ["CREATE", key, group, id];
52
- if (opts?.mkstream) args.push("MKSTREAM");
62
+ const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
53
63
  try {
54
- await redis.send("XGROUP", args);
64
+ await typedStreams(redis).xgroup("CREATE", key, group, id, ...extra);
55
65
  } catch (err) {
56
66
  const msg = err instanceof Error ? err.message : String(err);
57
67
  if (!/BUSYGROUP/i.test(msg)) throw err;
58
68
  }
59
69
  },
60
70
  async xreadgroup(group, consumer, key, count) {
61
- const reply = await redis.send("XREADGROUP", [
71
+ const reply = await typedStreams(redis).xreadgroup(
62
72
  "GROUP",
63
73
  group,
64
74
  consumer,
65
75
  "COUNT",
66
- String(count),
76
+ count,
67
77
  "STREAMS",
68
78
  key,
69
79
  ">",
70
- ]);
80
+ );
71
81
  return parseXreadgroupReply(reply);
72
82
  },
73
83
  async xack(key, group, id) {
74
- return Number(await redis.send("XACK", [key, group, id]));
84
+ return Number(await typedStreams(redis).xack(key, group, id));
75
85
  },
76
86
  async publish(channel, message) {
77
87
  return redis.publish(channel, message);
@@ -69,8 +69,9 @@ export interface SignalMessage {
69
69
  }
70
70
 
71
71
  /** Dead-letter entry with full attempt history. */
72
- export interface DeadLetter extends SignalMessage {
72
+ export interface DeadLetter<T = unknown> extends Omit<SignalMessage, "payload" | "status"> {
73
73
  readonly status: "dead";
74
+ readonly payload: T;
74
75
  }
75
76
 
76
77
  /** Per-subscriber lag / errors (broadcast physics). */
@@ -83,7 +83,7 @@ export interface SqlDriver {
83
83
  /** Key-value namespace handle. */
84
84
  export interface KvNamespace {
85
85
  /** Protocol driver id. */
86
- readonly driverId: "memory" | "redis";
86
+ readonly driverId: "memory" | "redis" | "postgres" | "pglite";
87
87
  /**
88
88
  * Read a key.
89
89
  *