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
@@ -1,13 +1,58 @@
1
1
  /**
2
2
  * Next-occurrence helpers for cron / every schedules (console §9.6 timeline).
3
3
  *
4
- * Supports `every` intervals and simple daily cron (`M H * * *` / `M H * * DOW`).
5
- * Complex cron expressions fall back to a single `nextRunAt` when present.
4
+ * Cron math uses {@link Bun.cron.parse} (5-field + nicknames + `{ tz }`).
5
+ * Interval (`every`) stays on {@link parseDurationMs}.
6
6
  */
7
7
 
8
8
  import { parseDurationMs } from "./duration.ts";
9
9
  import { effectiveSchedule, type CronRow } from "./reconcile.ts";
10
10
 
11
+ /**
12
+ * Bun 1.4 `{ tz }` parse — `@types/bun` 1.3.14 only declares two arguments.
13
+ *
14
+ * @param expression - 5-field expression or nickname
15
+ * @param relativeMs - Exclusive start
16
+ * @param timezone - IANA zone
17
+ */
18
+ function parseCronNext(expression: string, relativeMs: number, timezone: string): Date | null {
19
+ return (
20
+ Bun.cron.parse as (
21
+ expression: string,
22
+ relativeDate?: Date | number,
23
+ options?: { readonly tz?: string },
24
+ ) => Date | null
25
+ )(expression, relativeMs, { tz: timezone });
26
+ }
27
+
28
+ /** Cap walk loops so a `* * * * *` range cannot hang the Console timeline. */
29
+ const MAX_CRON_FIRES = 10_000;
30
+ /** How far back {@link previousOccurrence} searches for a prior cron fire. */
31
+ const PREVIOUS_LOOKBACK_MS = 400 * 86_400_000;
32
+
33
+ /**
34
+ * Next matching UTC epoch-ms after `relativeMs`, or `undefined` when the
35
+ * expression is invalid or has no match within eight years.
36
+ *
37
+ * {@link Bun.cron.parse} is exclusive of `relativeMs`.
38
+ *
39
+ * @param cron - 5-field expression or nickname (`@hourly`)
40
+ * @param relativeMs - Search starts after this instant
41
+ * @param timezone - IANA zone passed as `{ tz }`
42
+ */
43
+ export function nextCronFireAt(
44
+ cron: string,
45
+ relativeMs: number,
46
+ timezone: string,
47
+ ): number | undefined {
48
+ try {
49
+ const next = parseCronNext(cron, relativeMs, timezone);
50
+ return next === null ? undefined : next.getTime();
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
11
56
  /**
12
57
  * Compute fire times in `[from, until)` for a reconciled cron row.
13
58
  *
@@ -24,7 +69,7 @@ export function nextOccurrences(row: CronRow, from: number, until: number): read
24
69
  return nextEveryOccurrences(row, sched.every, from, until);
25
70
  }
26
71
  if (sched.cron) {
27
- return nextCronOccurrences(row, sched.cron, sched.timezone, from, until);
72
+ return nextCronOccurrences(sched.cron, row.timezone, from, until);
28
73
  }
29
74
  return [];
30
75
  }
@@ -41,25 +86,12 @@ export function previousOccurrence(row: CronRow, at: number): number | null {
41
86
  const interval = parseDurationMs(sched.every);
42
87
  if (interval <= 0) return null;
43
88
  if (row.lastRunAt === undefined) return null;
44
- // Floor to the slot boundary at or before `at`.
45
89
  const origin = row.lastRunAt % interval;
46
90
  const slot = at - ((at - origin) % interval);
47
91
  return slot > 0 ? slot : null;
48
92
  }
49
93
  if (sched.cron) {
50
- const parsed = parseSimpleDaily(sched.cron);
51
- if (!parsed) return null;
52
- // Walk back up to 400 days for a prior civil fire.
53
- for (let day = 1; day <= 400; day++) {
54
- const probe = at - day * 86_400_000;
55
- const local = zonedParts(probe, sched.timezone);
56
- if (local.hour === parsed.hour && local.minute === parsed.minute) {
57
- // Align to that local wall time's UTC instant near `probe`.
58
- const fires = civilFiresOnDay(local.date, parsed, sched.timezone);
59
- const prior = fires.filter((t) => t <= at).pop();
60
- if (prior !== undefined) return prior;
61
- }
62
- }
94
+ return previousCronFire(sched.cron, row.timezone, at);
63
95
  }
64
96
  return null;
65
97
  }
@@ -77,11 +109,10 @@ export function countMissedOccurrences(row: CronRow, from: number, until: number
77
109
  if (sched.every) {
78
110
  const interval = parseDurationMs(sched.every);
79
111
  if (interval <= 0) return 0;
80
- return Math.max(0, Math.floor((until - from) / interval) - 0);
112
+ return Math.max(0, Math.floor((until - from) / interval));
81
113
  }
82
114
  if (sched.cron) {
83
- // Count fires in (from, until].
84
- const fires = nextCronOccurrences(row, sched.cron, sched.timezone, from + 1, until + 1);
115
+ const fires = nextCronOccurrences(sched.cron, row.timezone, from + 1, until + 1);
85
116
  return fires.filter((t) => t > from && t <= until).length;
86
117
  }
87
118
  return 0;
@@ -102,145 +133,43 @@ function nextEveryOccurrences(
102
133
  : row.lastRunAt !== undefined
103
134
  ? row.lastRunAt + interval
104
135
  : from;
105
- // If still in the past relative to `from`, advance to first >= from.
106
136
  while (t < from) t += interval;
107
137
  while (t < until) {
108
138
  out.push(t);
109
139
  t += interval;
110
- if (out.length > 10_000) break;
140
+ if (out.length > MAX_CRON_FIRES) break;
111
141
  }
112
142
  return out;
113
143
  }
114
144
 
115
145
  function nextCronOccurrences(
116
- row: CronRow,
117
146
  cron: string,
118
147
  timezone: string,
119
148
  from: number,
120
149
  until: number,
121
150
  ): readonly number[] {
122
- const parsed = parseSimpleDaily(cron);
123
- if (!parsed) {
124
- if (row.nextRunAt !== undefined && row.nextRunAt >= from && row.nextRunAt < until) {
125
- return [row.nextRunAt];
126
- }
127
- return [];
128
- }
129
-
130
151
  const out: number[] = [];
131
- const start = new Date(from);
132
- for (let day = 0; day < 400; day++) {
133
- const probe = Date.UTC(
134
- start.getUTCFullYear(),
135
- start.getUTCMonth(),
136
- start.getUTCDate() + day,
137
- 12,
138
- 0,
139
- 0,
140
- );
141
- if (probe > until + 86_400_000) break;
142
- const local = zonedParts(probe, timezone);
143
- const fires = civilFiresOnDay(local.date, parsed, timezone);
144
- for (const t of fires) {
145
- if (t >= from && t < until) out.push(t);
146
- }
147
- if (out.length > 10_000) break;
152
+ // parse is exclusive of the cursor — step back 1ms so `from` itself can match.
153
+ let cursor = from - 1;
154
+ while (out.length < MAX_CRON_FIRES) {
155
+ const t = nextCronFireAt(cron, cursor, timezone);
156
+ if (t === undefined || t >= until) break;
157
+ if (t >= from) out.push(t);
158
+ cursor = t;
148
159
  }
149
- return out.sort((a, b) => a - b);
150
- }
151
-
152
- /** Parsed simple daily cron. */
153
- interface SimpleDaily {
154
- readonly minute: number;
155
- readonly hour: number;
156
- readonly dow?: number;
160
+ return out;
157
161
  }
158
162
 
159
- function parseSimpleDaily(cron: string): SimpleDaily | null {
160
- const parts = cron.trim().split(/\s+/);
161
- if (parts.length < 5) return null;
162
- const minute = parts[0]!;
163
- const hour = parts[1]!;
164
- const dom = parts[2]!;
165
- const mon = parts[3]!;
166
- const dow = parts[4]!;
167
- if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return null;
168
- if (dom !== "*" || mon !== "*") return null;
169
- const m = Number(minute);
170
- const h = Number(hour);
171
- if (m < 0 || m > 59 || h < 0 || h > 23) return null;
172
- if (dow === "*") return { minute: m, hour: h };
173
- if (/^\d+$/.test(dow)) {
174
- const d = Number(dow);
175
- if (d < 0 || d > 6) return null;
176
- return { minute: m, hour: h, dow: d };
163
+ function previousCronFire(cron: string, timezone: string, at: number): number | null {
164
+ let cursor = at - PREVIOUS_LOOKBACK_MS;
165
+ let prev: number | null = null;
166
+ let steps = 0;
167
+ while (steps < MAX_CRON_FIRES) {
168
+ const t = nextCronFireAt(cron, cursor, timezone);
169
+ if (t === undefined || t >= at) break;
170
+ prev = t;
171
+ cursor = t;
172
+ steps++;
177
173
  }
178
- return null;
179
- }
180
-
181
- function civilFiresOnDay(
182
- dateStr: string,
183
- parsed: SimpleDaily,
184
- timezone: string,
185
- ): readonly number[] {
186
- const matches: number[] = [];
187
- const base = Date.parse(`${dateStr}T00:00:00.000Z`);
188
- if (Number.isNaN(base)) return [];
189
-
190
- for (let offsetMin = -14 * 60; offsetMin <= 14 * 60; offsetMin += 15) {
191
- const utc = base + parsed.hour * 3_600_000 + parsed.minute * 60_000 - offsetMin * 60_000;
192
- const local = zonedParts(utc, timezone);
193
- if (local.date === dateStr && local.hour === parsed.hour && local.minute === parsed.minute) {
194
- if (parsed.dow !== undefined) {
195
- const localDow = zonedDow(utc, timezone);
196
- if (localDow !== parsed.dow) continue;
197
- }
198
- if (!matches.includes(utc)) matches.push(utc);
199
- }
200
- }
201
- return matches;
202
- }
203
-
204
- interface ZonedParts {
205
- readonly date: string;
206
- readonly hour: number;
207
- readonly minute: number;
208
- }
209
-
210
- function zonedParts(utcMs: number, timezone: string): ZonedParts {
211
- const fmt = new Intl.DateTimeFormat("en-US", {
212
- timeZone: timezone,
213
- year: "numeric",
214
- month: "2-digit",
215
- day: "2-digit",
216
- hour: "2-digit",
217
- minute: "2-digit",
218
- hourCycle: "h23",
219
- });
220
- const parts = fmt.formatToParts(new Date(utcMs));
221
- const get = (type: Intl.DateTimeFormatPartTypes): string =>
222
- parts.find((p) => p.type === type)?.value ?? "0";
223
- return {
224
- date: `${get("year")}-${get("month")}-${get("day")}`,
225
- hour: Number(get("hour")),
226
- minute: Number(get("minute")),
227
- };
228
- }
229
-
230
- function zonedDow(utcMs: number, timezone: string): number {
231
- const fmt = new Intl.DateTimeFormat("en-US", {
232
- timeZone: timezone,
233
- weekday: "short",
234
- });
235
- const w = fmt.format(new Date(utcMs));
236
- const map: Record<string, number> = {
237
- Sun: 0,
238
- Mon: 1,
239
- Tue: 2,
240
- Wed: 3,
241
- Thu: 4,
242
- Fri: 5,
243
- Sat: 6,
244
- };
245
- return map[w] ?? 0;
174
+ return prev;
246
175
  }
@@ -466,8 +466,9 @@ describe("DST ambiguity", () => {
466
466
  expect(doctorSrc).not.toMatch(/detectDstAmbiguity/);
467
467
  });
468
468
 
469
- test("overlap day: two civil instants; lease does not span the gap; stub isDue avoids walk", async () => {
470
- // US fall-back 2026-11-01: 01:30 local occurs twice (~1h apart in UTC).
469
+ test("overlap day: crontab fires once; next slot is the following day", async () => {
470
+ // US fall-back 2026-11-01: 01:30 local occurs twice. Bun.cron.parse
471
+ // matches crontab — a fixed wall time fires at the first occurrence only.
471
472
  const from = Date.UTC(2026, 10, 1, 0, 0, 0);
472
473
  const until = Date.UTC(2026, 10, 2, 0, 0, 0);
473
474
  const fires = nextOccurrences(
@@ -481,13 +482,11 @@ describe("DST ambiguity", () => {
481
482
  from,
482
483
  until,
483
484
  );
484
- expect(fires.length).toBe(2);
485
- expect(fires[1]! - fires[0]!).toBe(3_600_000);
485
+ expect(fires).toHaveLength(1);
486
+ expect(fires[0]).toBe(Date.UTC(2026, 10, 1, 5, 30, 0));
486
487
 
487
- // Stub isDue (nextRunAt unset after first fire): does not walk civil fires →
488
- // advancing across the overlap gap does not double-fire.
489
488
  const store = createMemoryCronStore();
490
- const rt = createTestClockRuntime(0, {
489
+ const rt = createTestClockRuntime(fires[0]! - 1, {
491
490
  store,
492
491
  instanceId: "dst-fire",
493
492
  leaseMs: 1_000,
@@ -503,43 +502,13 @@ describe("DST ambiguity", () => {
503
502
  rt.onCron("ambig", () => {
504
503
  ran.push(rt.now());
505
504
  });
505
+ rt.advance(2);
506
506
  expect((await rt.tick()).ran).toEqual(["ambig"]);
507
507
  rt.advance(3_600_000);
508
508
  expect((await rt.tick()).ran).toEqual([]);
509
509
  expect(ran).toHaveLength(1);
510
-
511
- // Lease TTL << 1h overlap gap: if nextRunAt is driven to the second civil
512
- // instant, a second fire CAN occur. Leader lease is not a DST policy.
513
- const store2 = createMemoryCronStore();
514
- const rt2 = createTestClockRuntime(fires[0]! - 1, {
515
- store: store2,
516
- instanceId: "dst-risk",
517
- leaseMs: 1_000,
518
- });
519
- rt2.register(
520
- clock("ambig2", {
521
- cron: "30 1 * * *",
522
- timezone: "America/New_York",
523
- }),
524
- );
525
- await rt2.reconcile();
526
- await store2.put({
527
- ...(await store2.get("ambig2"))!,
528
- nextRunAt: fires[0],
529
- });
530
- const ran2: number[] = [];
531
- rt2.onCron("ambig2", () => {
532
- ran2.push(rt2.now());
533
- });
534
- rt2.advance(2);
535
- expect((await rt2.tick()).ran).toEqual(["ambig2"]);
536
- rt2.advance(fires[1]! - rt2.now());
537
- await store2.put({
538
- ...(await store2.get("ambig2"))!,
539
- nextRunAt: fires[1],
540
- });
541
- expect((await rt2.tick()).ran).toEqual(["ambig2"]);
542
- expect(ran2).toHaveLength(2);
510
+ const after = await store.get("ambig");
511
+ expect(after?.nextRunAt).toBeGreaterThan(fires[0]! + 3_600_000);
543
512
  });
544
513
  });
545
514
 
@@ -50,7 +50,12 @@ export { runDurable, type DurableResult, type RunDurableOptions } from "./clock/
50
50
 
51
51
  export { cronHealth, type CatchUpPolicy, type CronHealth } from "./clock/health.ts";
52
52
 
53
- export { nextOccurrences, previousOccurrence, countMissedOccurrences } from "./clock/schedule.ts";
53
+ export {
54
+ nextOccurrences,
55
+ previousOccurrence,
56
+ countMissedOccurrences,
57
+ nextCronFireAt,
58
+ } from "./clock/schedule.ts";
54
59
 
55
60
  export {
56
61
  pauseCron,
@@ -16,8 +16,8 @@ import { takeRate } from "./strategies.ts";
16
16
 
17
17
  /** KV surface required for rate gates. */
18
18
  export interface GateKv {
19
- /** Driver id when known (`memory` · `redis`). */
20
- readonly driverId?: "memory" | "redis";
19
+ /** Driver id when known (`memory` · `redis`; SQL ids unused on rate gates). */
20
+ readonly driverId?: "memory" | "redis" | "postgres" | "pglite";
21
21
  eval<T = unknown>(script: string, keys: readonly string[], args?: readonly string[]): Promise<T>;
22
22
  }
23
23
 
@@ -46,7 +46,7 @@ export interface GateRuntime {
46
46
  /** Registered declarations by name. */
47
47
  readonly gates: ReadonlyMap<string, GateDecl>;
48
48
  /** KV backend used for rate limits (`memory` · `redis`, or unset). */
49
- readonly kvDriverId: "memory" | "redis" | undefined;
49
+ readonly kvDriverId: "memory" | "redis" | "postgres" | "pglite" | undefined;
50
50
  /**
51
51
  * Evaluate a gate chain in order; stop on first denial.
52
52
  *
@@ -176,6 +176,8 @@ export type {
176
176
  AiModelDecl,
177
177
  AiPromptDecl,
178
178
  AiAgentDecl,
179
+ AiMcpServerDecl,
180
+ AiMcpServerOptions,
179
181
  AiEmbedDecl,
180
182
  AiRuntime,
181
183
  AgentDenial,
@@ -34,7 +34,8 @@ export interface SignalOptions {
34
34
  }
35
35
 
36
36
  /**
37
- * Declared signal handle — usable as `on(signal, flow)` and `fx.emit(signal, …)`.
37
+ * Declared signal handle — usable as `on(signal, flow)`, `fx.emit(signal, …)`,
38
+ * and `fx.deadLetters(signal)`.
38
39
  */
39
40
  export interface SignalDecl<T = unknown> {
40
41
  /** Signal name (manifest key). */
@@ -3,7 +3,12 @@
3
3
  * emit / subscribe / live / drain for `fx` and tests.
4
4
  */
5
5
 
6
- import type { SignalBus, SignalDriver, SignalEmitOptions } from "../../drivers/signal-types.ts";
6
+ import type {
7
+ DeadLetter,
8
+ SignalBus,
9
+ SignalDriver,
10
+ SignalEmitOptions,
11
+ } from "../../drivers/signal-types.ts";
7
12
  import type { SignalDecl } from "./declare.ts";
8
13
 
9
14
  /** Options for {@link createSignalRuntime}. */
@@ -48,6 +53,12 @@ export interface SignalRuntime {
48
53
  * @param options - Optional emit options (`key` for per-key once ordering)
49
54
  */
50
55
  emit(name: string, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
56
+ /**
57
+ * Dead-lettered messages for one signal (auto-starts).
58
+ *
59
+ * @param name - Signal name
60
+ */
61
+ deadLetters(name: string): Promise<readonly DeadLetter[]>;
51
62
  /** Close the bus. */
52
63
  close(): Promise<void>;
53
64
  }
@@ -87,6 +98,10 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
87
98
  const b = await this.start();
88
99
  await b.emit(name, payload, options);
89
100
  },
101
+ async deadLetters(name) {
102
+ const b = await this.start();
103
+ return b.deadLetters(name);
104
+ },
90
105
  async close() {
91
106
  if (bus) {
92
107
  await bus.close();
@@ -8,6 +8,7 @@
8
8
 
9
9
  export { signal } from "./signal/declare.ts";
10
10
  export type { SignalDecl, SignalOptions } from "./signal/declare.ts";
11
+ export type { DeadLetter, SignalFailureReason } from "../drivers/signal-types.ts";
11
12
 
12
13
  export { createSignalRuntime } from "./signal/runtime.ts";
13
14
  export type { CreateSignalRuntimeOptions, SignalRuntime } from "./signal/runtime.ts";
@@ -92,6 +92,7 @@ describe("autoCacheEligible", () => {
92
92
  false,
93
93
  );
94
94
  expect(autoCacheEligible({ effects: { reads: ["runs"] } })).toBe(false);
95
+ expect(autoCacheEligible({ effects: { reads: ["signal:notify"] } })).toBe(false);
95
96
  expect(autoCacheEligible({ effects: {} })).toBe(false);
96
97
  });
97
98
 
@@ -112,6 +113,7 @@ describe("autoCacheEligible", () => {
112
113
  { kind: "read", resource: "sql:views" },
113
114
  { kind: "read", resource: "sql:views" },
114
115
  { kind: "read", resource: "runs" },
116
+ { kind: "read", resource: "signal:notify" },
115
117
  { kind: "write", resource: "sql:views" },
116
118
  { kind: "ask", resource: "summarize" },
117
119
  { kind: "emit", resource: "view-changed" },
@@ -53,7 +53,7 @@ export function tier1KeysForReads(
53
53
  effects: Effects,
54
54
  dimsByResource?: Readonly<Record<string, readonly string[]>>,
55
55
  ): string[] {
56
- const reads = (effects.reads ?? []).filter((r): r is ResourceRef => r !== "runs");
56
+ const reads = (effects.reads ?? []).filter((r): r is ResourceRef => isStoreResourceRef(r));
57
57
  return reads.map((resource) => computedCacheKey(resource, dimsByResource?.[resource]));
58
58
  }
59
59
 
@@ -223,7 +223,7 @@ export function resolveCacheEffects(
223
223
  declared: Effects | undefined,
224
224
  learnedReads: readonly ResourceRef[] | undefined,
225
225
  ): Effects {
226
- const declaredReads = (declared?.reads ?? []).filter((r) => r !== "runs");
226
+ const declaredReads = (declared?.reads ?? []).filter((r) => isStoreResourceRef(r));
227
227
  if (declaredReads.length > 0 || (declared?.writes?.length ?? 0) > 0) {
228
228
  return declared ?? {};
229
229
  }
@@ -275,7 +275,7 @@ export function autoCacheEligible(options: {
275
275
  const effects = options.effects ?? {};
276
276
  if ((effects.asks?.length ?? 0) > 0) return false;
277
277
  if ((effects.writes?.length ?? 0) > 0) return false;
278
- const reads = (effects.reads ?? []).filter((r) => r !== "runs");
278
+ const reads = (effects.reads ?? []).filter((r) => isStoreResourceRef(r));
279
279
  return reads.length > 0;
280
280
  }
281
281
 
@@ -74,6 +74,11 @@ export interface SqlStoreDecl extends StoreDeclBase {
74
74
  export interface KvStoreOptions {
75
75
  /** Optional description. */
76
76
  readonly description?: string;
77
+ /**
78
+ * Persist this namespace in SQL (`oke_kv` JSONB on `DATABASE_URL`),
79
+ * not the cache Redis (`REDIS_URL`). Distinct from Flow `durable`.
80
+ */
81
+ readonly durable?: boolean;
77
82
  }
78
83
 
79
84
  /** KV store declaration. */
@@ -81,6 +86,7 @@ export interface KvStoreDecl extends StoreDeclBase {
81
86
  readonly facet: "kv";
82
87
  readonly ref: `kv:${string}`;
83
88
  readonly description?: string;
89
+ readonly durable?: boolean;
84
90
  }
85
91
 
86
92
  /** Options for {@link store.files}. */
@@ -179,6 +185,7 @@ export function kv(name: string, options: KvStoreOptions = {}): KvStoreDecl {
179
185
  name,
180
186
  ref: `kv:${name}`,
181
187
  ...(options.description !== undefined ? { description: options.description } : {}),
188
+ ...(options.durable === true ? { durable: true } : {}),
182
189
  };
183
190
  }
184
191
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Durable KV on `oke_kv` JSONB — round-trip, TTL purge, cache Redis unchanged.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { pgliteDriver } from "../../drivers/pglite.ts";
7
+ import { createRedisFakeClient, redisDriver } from "../../drivers/redis.ts";
8
+ import { memoryDrivers } from "../../drivers/memory.ts";
9
+ import { store } from "./declare.ts";
10
+ import { openSqlKvNamespace } from "./kv-sql.ts";
11
+ import { createStoreRuntime, type KvStoreFxHandle } from "./runtime.ts";
12
+
13
+ const ctx = { effects: {} };
14
+
15
+ describe("oke_kv JSONB durable namespace", () => {
16
+ test("set/get/delete round-trips a JSON object", async () => {
17
+ const conn = await pgliteDriver.connect({ url: "memory://" });
18
+ try {
19
+ const ns = await openSqlKvNamespace({
20
+ conn,
21
+ namespace: "ledger",
22
+ now: () => 1_000_000,
23
+ });
24
+ expect(ns.driverId).toBe("pglite");
25
+ await ns.set("idemp", { orderId: "o1", n: 2 });
26
+ expect(await ns.get("idemp")).toEqual({ orderId: "o1", n: 2 });
27
+ expect(await ns.list("id")).toEqual(["idemp"]);
28
+ expect(await ns.delete("idemp")).toBe(true);
29
+ expect(await ns.get("idemp")).toBeUndefined();
30
+ } finally {
31
+ await conn.close();
32
+ }
33
+ });
34
+
35
+ test("expired keys are invisible and purgeExpired removes the row", async () => {
36
+ const conn = await pgliteDriver.connect({ url: "memory://" });
37
+ let t = 1_000_000;
38
+ try {
39
+ const ns = await openSqlKvNamespace({
40
+ conn,
41
+ namespace: "ledger",
42
+ now: () => t,
43
+ });
44
+ await ns.set("fresh", { ok: true }, "5s");
45
+ await ns.set("stale", { ok: false }, "1s");
46
+ t += 1_500;
47
+ expect(await ns.get("stale")).toBeUndefined();
48
+ expect(await ns.get("fresh")).toEqual({ ok: true });
49
+ const removed = await ns.purgeExpired();
50
+ expect(removed).toBe(1);
51
+ const leftover = await conn.query(`SELECT key FROM oke_kv WHERE key = ?`, ["stale"]);
52
+ expect(leftover).toEqual([]);
53
+ } finally {
54
+ await conn.close();
55
+ }
56
+ });
57
+
58
+ test("non-durable namespace stays on redis; durable uses pglite", async () => {
59
+ const fake = createRedisFakeClient();
60
+ const sessions = store.kv("sessions");
61
+ const ledger = store.kv("ledger", { durable: true });
62
+ const runtime = createStoreRuntime({
63
+ drivers: {
64
+ sql: pgliteDriver,
65
+ kv: redisDriver,
66
+ files: memoryDrivers.files,
67
+ index: memoryDrivers.index,
68
+ },
69
+ sqlUrl: "memory://",
70
+ kv: { sessions: { url: "redis://cache", client: fake } },
71
+ now: () => 1_000_000,
72
+ });
73
+ runtime.register(sessions);
74
+ runtime.register(ledger);
75
+ const cache = (await runtime.open(sessions, ctx)) as KvStoreFxHandle;
76
+ const durable = (await runtime.open(ledger, ctx)) as KvStoreFxHandle;
77
+ expect(cache.driverId).toBe("redis");
78
+ expect(durable.driverId).toBe("pglite");
79
+ await cache.set("sid", { user: "a" });
80
+ await durable.set("idemp", { orderId: "o1" });
81
+ expect(await cache.get("sid")).toEqual({ user: "a" });
82
+ expect(await durable.get("idemp")).toEqual({ orderId: "o1" });
83
+ expect([...fake.data.keys()].some((k) => k.includes("idemp"))).toBe(false);
84
+ await runtime.close();
85
+ });
86
+ });