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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +21 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/providers/index.mdx +1 -0
- package/site/content/docs/recipes/dragonfly.mdx +4 -3
- package/site/content/docs/recipes/redis.mdx +4 -5
- package/site/content/docs/recipes/valkey.mdx +5 -3
- package/site/content/docs/reference/configuration.mdx +3 -1
- package/site/content/docs/reference/environment-variables.mdx +5 -5
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/cli/load-config.images.test.ts +1 -0
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +68 -1
- package/src/compiler/extract.ts +70 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/docker/helpers.ts +28 -0
- package/src/docker/recipes/dragonfly.ts +3 -6
- package/src/docker/recipes/redis.ts +2 -6
- package/src/docker/recipes/valkey.ts +2 -6
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/drivers/types.ts +1 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/gate/runtime.ts +3 -3
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/kv-sql.test.ts +86 -0
- package/src/elements/store/kv-sql.ts +178 -0
- package/src/elements/store/runtime.ts +35 -9
- package/src/elements/store.test.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot-bind/store.test.ts +150 -1
- package/src/kernel/boot-bind/store.ts +66 -1
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/diff.test.ts +13 -0
- package/src/manifest/diff.ts +15 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +33 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- 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
|
-
*
|
|
5
|
-
*
|
|
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(
|
|
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
|
-
|
|
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)
|
|
112
|
+
return Math.max(0, Math.floor((until - from) / interval));
|
|
81
113
|
}
|
|
82
114
|
if (sched.cron) {
|
|
83
|
-
|
|
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 >
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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:
|
|
470
|
-
// US fall-back 2026-11-01: 01:30 local occurs twice
|
|
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
|
|
485
|
-
expect(fires[
|
|
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
|
-
|
|
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
|
|
package/src/elements/clock.ts
CHANGED
|
@@ -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 {
|
|
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
|
*
|
package/src/elements/index.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface SignalOptions {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Declared signal handle — usable as `on(signal, flow)
|
|
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 {
|
|
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();
|
package/src/elements/signal.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
});
|