okengine 0.6.1 → 0.7.0
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/AGENTS.md +2 -2
- package/README.md +73 -181
- package/package.json +2 -2
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +19 -13
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +13 -13
- package/site/content/docs/reference/environment-variables.mdx +12 -10
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/index.ts +3 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphan reconciliation vs in-flight / DLQ messages.
|
|
3
|
+
*
|
|
4
|
+
* Config rows become orphaned when absent from declarations; durable messages
|
|
5
|
+
* remain. Drain/replay require an active declaration — recovery is re-declare.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { memorySignalDriver } from "../../drivers/index.ts";
|
|
14
|
+
import { signal } from "./declare.ts";
|
|
15
|
+
import { createMemorySignalConfigStore, reconcileSignals } from "./reconcile.ts";
|
|
16
|
+
import { createSignalRuntime } from "./runtime.ts";
|
|
17
|
+
|
|
18
|
+
const openRuntimes: Array<{ close(): Promise<void> }> = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
while (openRuntimes.length) {
|
|
22
|
+
await openRuntimes.pop()!.close();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("orphan signal messages", () => {
|
|
27
|
+
test("orphaned config keeps durable DLQ; replay needs re-declare", async () => {
|
|
28
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-signal-orphan-"));
|
|
29
|
+
const durablePath = join(dir, "bus.json");
|
|
30
|
+
try {
|
|
31
|
+
const legacy = signal("legacy-shipped", {
|
|
32
|
+
delivery: "once",
|
|
33
|
+
retries: 0,
|
|
34
|
+
deadLetter: true,
|
|
35
|
+
});
|
|
36
|
+
const runtime = createSignalRuntime({
|
|
37
|
+
driver: memorySignalDriver,
|
|
38
|
+
durablePath,
|
|
39
|
+
});
|
|
40
|
+
openRuntimes.push(runtime);
|
|
41
|
+
runtime.register(legacy);
|
|
42
|
+
const bus = await runtime.start();
|
|
43
|
+
|
|
44
|
+
await bus.subscribe("legacy-shipped", "c1", async () => {
|
|
45
|
+
throw new Error("gone");
|
|
46
|
+
});
|
|
47
|
+
await bus.emit("legacy-shipped", { shipmentId: "shp_9" });
|
|
48
|
+
await bus.drain();
|
|
49
|
+
expect(await bus.deadLetters("legacy-shipped")).toHaveLength(1);
|
|
50
|
+
await runtime.close();
|
|
51
|
+
openRuntimes.pop();
|
|
52
|
+
|
|
53
|
+
// Config reconciliation: signal removed from code → orphaned, not deleted.
|
|
54
|
+
const store = createMemorySignalConfigStore();
|
|
55
|
+
await reconcileSignals([legacy], store);
|
|
56
|
+
const after = await reconcileSignals([], store);
|
|
57
|
+
expect(after.orphaned).toEqual(["legacy-shipped"]);
|
|
58
|
+
expect(after.rows.find((r) => r.name === "legacy-shipped")?.status).toBe("orphaned");
|
|
59
|
+
|
|
60
|
+
// Bus without the declaration: messages still load, but ops require decl.
|
|
61
|
+
const emptyRuntime = createSignalRuntime({
|
|
62
|
+
driver: memorySignalDriver,
|
|
63
|
+
durablePath,
|
|
64
|
+
});
|
|
65
|
+
openRuntimes.push(emptyRuntime);
|
|
66
|
+
const emptyBus = await emptyRuntime.start();
|
|
67
|
+
await expect(
|
|
68
|
+
emptyBus.replay({
|
|
69
|
+
signal: "legacy-shipped",
|
|
70
|
+
ratePerSec: 10,
|
|
71
|
+
dryRun: false,
|
|
72
|
+
}),
|
|
73
|
+
).rejects.toThrow(/Unknown signal/);
|
|
74
|
+
await emptyRuntime.close();
|
|
75
|
+
openRuntimes.pop();
|
|
76
|
+
|
|
77
|
+
// Re-declare → DLQ processable again via replay.
|
|
78
|
+
const restored = signal("legacy-shipped", {
|
|
79
|
+
delivery: "once",
|
|
80
|
+
retries: 0,
|
|
81
|
+
deadLetter: true,
|
|
82
|
+
});
|
|
83
|
+
const again = await reconcileSignals([restored], store);
|
|
84
|
+
expect(again.active).toEqual(["legacy-shipped"]);
|
|
85
|
+
expect(again.orphaned).toEqual([]);
|
|
86
|
+
|
|
87
|
+
const restoreRuntime = createSignalRuntime({
|
|
88
|
+
driver: memorySignalDriver,
|
|
89
|
+
durablePath,
|
|
90
|
+
});
|
|
91
|
+
openRuntimes.push(restoreRuntime);
|
|
92
|
+
restoreRuntime.register(restored);
|
|
93
|
+
const restoreBus = await restoreRuntime.start();
|
|
94
|
+
const seen: unknown[] = [];
|
|
95
|
+
await restoreBus.subscribe("legacy-shipped", "c2", async (m) => {
|
|
96
|
+
seen.push(m.payload);
|
|
97
|
+
});
|
|
98
|
+
const result = await restoreBus.replay({
|
|
99
|
+
signal: "legacy-shipped",
|
|
100
|
+
ratePerSec: 100,
|
|
101
|
+
dryRun: false,
|
|
102
|
+
});
|
|
103
|
+
expect(result.succeeded).toBe(1);
|
|
104
|
+
expect(seen).toEqual([{ shipmentId: "shp_9" }]);
|
|
105
|
+
} finally {
|
|
106
|
+
await rm(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("pending messages for an undeclared signal are not drained", async () => {
|
|
111
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-signal-orphan-pend-"));
|
|
112
|
+
const durablePath = join(dir, "bus.json");
|
|
113
|
+
try {
|
|
114
|
+
const once = signal("legacy-shipped", {
|
|
115
|
+
delivery: "once",
|
|
116
|
+
optional: true,
|
|
117
|
+
});
|
|
118
|
+
const runtime = createSignalRuntime({
|
|
119
|
+
driver: memorySignalDriver,
|
|
120
|
+
durablePath,
|
|
121
|
+
});
|
|
122
|
+
openRuntimes.push(runtime);
|
|
123
|
+
runtime.register(once);
|
|
124
|
+
const bus = await runtime.start();
|
|
125
|
+
await bus.emit("legacy-shipped", { id: 1 });
|
|
126
|
+
await runtime.close();
|
|
127
|
+
openRuntimes.pop();
|
|
128
|
+
|
|
129
|
+
const other = signal("order-placed", { delivery: "once" });
|
|
130
|
+
const next = createSignalRuntime({
|
|
131
|
+
driver: memorySignalDriver,
|
|
132
|
+
durablePath,
|
|
133
|
+
});
|
|
134
|
+
openRuntimes.push(next);
|
|
135
|
+
next.register(other);
|
|
136
|
+
const nextBus = await next.start();
|
|
137
|
+
const seen: unknown[] = [];
|
|
138
|
+
await nextBus.subscribe("order-placed", "c1", async (m) => {
|
|
139
|
+
seen.push(m.payload);
|
|
140
|
+
});
|
|
141
|
+
// Even a subscriber on a different name cannot claim orphaned-name rows,
|
|
142
|
+
// and legacy-shipped has no declaration so it cannot be subscribed.
|
|
143
|
+
await nextBus.drain();
|
|
144
|
+
expect(seen).toEqual([]);
|
|
145
|
+
await expect(nextBus.subscribe("legacy-shipped", "x", async () => {})).rejects.toThrow(
|
|
146
|
+
/Unknown signal/,
|
|
147
|
+
);
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(dir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* emit / subscribe / live / drain for `fx` and tests.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { SignalDriver,
|
|
6
|
+
import type { SignalBus, SignalDriver, SignalEmitOptions } from "../../drivers/signal-types.ts";
|
|
7
7
|
import type { SignalDecl } from "./declare.ts";
|
|
8
8
|
|
|
9
9
|
/** Options for {@link createSignalRuntime}. */
|
|
@@ -12,6 +12,8 @@ export interface CreateSignalRuntimeOptions {
|
|
|
12
12
|
readonly driver: SignalDriver;
|
|
13
13
|
/** Clock. */
|
|
14
14
|
readonly now?: () => number;
|
|
15
|
+
/** Visibility lease for `once` claims (ms). */
|
|
16
|
+
readonly leaseMs?: number;
|
|
15
17
|
/** Durable path for chaos recovery. */
|
|
16
18
|
readonly durablePath?: string;
|
|
17
19
|
/** Injected clients forwarded to the driver. */
|
|
@@ -22,6 +24,8 @@ export interface CreateSignalRuntimeOptions {
|
|
|
22
24
|
|
|
23
25
|
/** Signal runtime. */
|
|
24
26
|
export interface SignalRuntime {
|
|
27
|
+
/** Bound protocol driver id (`memory` · `postgres` · `redis` · `nats`). */
|
|
28
|
+
readonly driverId: string;
|
|
25
29
|
/** Underlying bus (after {@link SignalRuntime.start}). */
|
|
26
30
|
readonly bus: SignalBus | null;
|
|
27
31
|
/**
|
|
@@ -41,8 +45,9 @@ export interface SignalRuntime {
|
|
|
41
45
|
*
|
|
42
46
|
* @param name - Signal name
|
|
43
47
|
* @param payload - Payload
|
|
48
|
+
* @param options - Optional emit options (`key` for per-key once ordering)
|
|
44
49
|
*/
|
|
45
|
-
emit(name: string, payload?: unknown): Promise<void>;
|
|
50
|
+
emit(name: string, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
|
|
46
51
|
/** Close the bus. */
|
|
47
52
|
close(): Promise<void>;
|
|
48
53
|
}
|
|
@@ -57,6 +62,7 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
|
|
|
57
62
|
let bus: SignalBus | null = null;
|
|
58
63
|
|
|
59
64
|
const runtime: SignalRuntime = {
|
|
65
|
+
driverId: options.driver.id,
|
|
60
66
|
get bus() {
|
|
61
67
|
return bus;
|
|
62
68
|
},
|
|
@@ -69,6 +75,7 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
|
|
|
69
75
|
bus = await options.driver.open({
|
|
70
76
|
signals: declarations,
|
|
71
77
|
now: options.now,
|
|
78
|
+
leaseMs: options.leaseMs,
|
|
72
79
|
durablePath: options.durablePath,
|
|
73
80
|
sql: options.sql,
|
|
74
81
|
redis: options.redis,
|
|
@@ -76,9 +83,9 @@ export function createSignalRuntime(options: CreateSignalRuntimeOptions): Signal
|
|
|
76
83
|
});
|
|
77
84
|
return bus;
|
|
78
85
|
},
|
|
79
|
-
async emit(name, payload) {
|
|
86
|
+
async emit(name, payload, options) {
|
|
80
87
|
const b = await this.start();
|
|
81
|
-
await b.emit(name, payload);
|
|
88
|
+
await b.emit(name, payload, options);
|
|
82
89
|
},
|
|
83
90
|
async close() {
|
|
84
91
|
if (bus) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal `schema` enforced at emit time via Standard Schema (same as Flow `in`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createPostgresSignalFake,
|
|
10
|
+
memorySignalDriver,
|
|
11
|
+
postgresSignalDriver,
|
|
12
|
+
type SignalBus,
|
|
13
|
+
type SignalDriver,
|
|
14
|
+
} from "../../drivers/index.ts";
|
|
15
|
+
import { OkeError, OKE_ERRORS } from "../../kernel/errors.ts";
|
|
16
|
+
import { signal, type SignalDecl } from "./declare.ts";
|
|
17
|
+
|
|
18
|
+
const openBuses: SignalBus[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
while (openBuses.length) {
|
|
22
|
+
await openBuses.pop()!.close();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function openBus(
|
|
27
|
+
driver: SignalDriver,
|
|
28
|
+
decls: readonly SignalDecl[],
|
|
29
|
+
extra: Record<string, unknown> = {},
|
|
30
|
+
): Promise<SignalBus> {
|
|
31
|
+
const signals = new Map(decls.map((d) => [d.name, d]));
|
|
32
|
+
const bus = await driver.open({ signals, ...extra });
|
|
33
|
+
openBuses.push(bus);
|
|
34
|
+
return bus;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const drivers: Array<{
|
|
38
|
+
label: string;
|
|
39
|
+
driver: SignalDriver;
|
|
40
|
+
setup?: () => Record<string, unknown>;
|
|
41
|
+
}> = [
|
|
42
|
+
{ label: "memory", driver: memorySignalDriver },
|
|
43
|
+
{
|
|
44
|
+
label: "postgres",
|
|
45
|
+
driver: postgresSignalDriver,
|
|
46
|
+
setup: () => ({ sql: createPostgresSignalFake() }),
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
for (const { label, driver, setup } of drivers) {
|
|
51
|
+
describe(`signal schema emit · ${label}`, () => {
|
|
52
|
+
test("invalid payload rejected at emit with OKE1043; valid succeeds", async () => {
|
|
53
|
+
const orderPlaced = signal("order-placed", {
|
|
54
|
+
delivery: "once",
|
|
55
|
+
schema: z.object({
|
|
56
|
+
orderId: z.string(),
|
|
57
|
+
total: z.number(),
|
|
58
|
+
}),
|
|
59
|
+
retries: 1,
|
|
60
|
+
deadLetter: true,
|
|
61
|
+
});
|
|
62
|
+
const bus = await openBus(driver, [orderPlaced], setup?.() ?? {});
|
|
63
|
+
const got: unknown[] = [];
|
|
64
|
+
await bus.subscribe("order-placed", "c1", async (m) => {
|
|
65
|
+
got.push(m.payload);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let rejected: unknown;
|
|
69
|
+
try {
|
|
70
|
+
await bus.emit("order-placed", { orderId: "ord_1", total: "not-a-number" });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
rejected = err;
|
|
73
|
+
}
|
|
74
|
+
expect(rejected).toBeInstanceOf(OkeError);
|
|
75
|
+
const oke = rejected as OkeError;
|
|
76
|
+
expect(oke.code).toBe(OKE_ERRORS.SIGNAL_SCHEMA.code);
|
|
77
|
+
expect(oke.message).toContain("OKE1043");
|
|
78
|
+
expect(oke.message).toContain("order-placed");
|
|
79
|
+
|
|
80
|
+
// Nothing staged while invalid.
|
|
81
|
+
await bus.drain();
|
|
82
|
+
expect(got).toEqual([]);
|
|
83
|
+
const statsBefore = await bus.inspect("order-placed");
|
|
84
|
+
expect(statsBefore[0]?.pending).toBe(0);
|
|
85
|
+
expect(statsBefore[0]?.delivered).toBe(0);
|
|
86
|
+
|
|
87
|
+
await bus.emit("order-placed", { orderId: "ord_1", total: 49.5 });
|
|
88
|
+
await bus.drain();
|
|
89
|
+
expect(got).toEqual([{ orderId: "ord_1", total: 49.5 }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("no schema: any payload still accepted", async () => {
|
|
93
|
+
const loose = signal("hook", {
|
|
94
|
+
delivery: "once",
|
|
95
|
+
optional: true,
|
|
96
|
+
});
|
|
97
|
+
const bus = await openBus(driver, [loose], setup?.() ?? {});
|
|
98
|
+
const got: unknown[] = [];
|
|
99
|
+
await bus.subscribe("hook", "c1", async (m) => {
|
|
100
|
+
got.push(m.payload);
|
|
101
|
+
});
|
|
102
|
+
await bus.emit("hook", { anything: true });
|
|
103
|
+
await bus.drain();
|
|
104
|
+
expect(got).toEqual([{ anything: true }]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* - delivery is mandatory with no default
|
|
4
4
|
* - rolled-back transaction emits nothing
|
|
5
5
|
* - crash between write and emit loses nothing (transactional outbox)
|
|
6
|
-
* - competing consumers under once receive each message
|
|
6
|
+
* - competing consumers under once receive each message once
|
|
7
7
|
* - broadcast reaches every subscriber
|
|
8
8
|
* - live is client-subscribable
|
|
9
9
|
* - DLQ preserves typed failure reasons per attempt
|
|
10
10
|
* - redis / nats keep an outbox relay (semantics never regress)
|
|
11
|
-
* - chaos: kill process mid-transaction
|
|
11
|
+
* - chaos: kill process mid-transaction; SIGKILL after claim + lease reclaim
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
@@ -148,6 +148,22 @@ for (const { label, driver, setup } of drivers) {
|
|
|
148
148
|
expect(numbers).toEqual(["1", "2"]);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test("once: a single emit is received by exactly one of two consumers", async () => {
|
|
152
|
+
const once = signal("order-placed", { delivery: "once", retries: 2 });
|
|
153
|
+
const bus = await openBus(driver, [once], setup?.() ?? {});
|
|
154
|
+
const got: string[] = [];
|
|
155
|
+
await bus.subscribe("order-placed", "worker-a", async () => {
|
|
156
|
+
got.push("a");
|
|
157
|
+
});
|
|
158
|
+
await bus.subscribe("order-placed", "worker-b", async () => {
|
|
159
|
+
got.push("b");
|
|
160
|
+
});
|
|
161
|
+
await bus.emit("order-placed", { n: 1 });
|
|
162
|
+
await bus.drain();
|
|
163
|
+
expect(got).toHaveLength(1);
|
|
164
|
+
expect(got[0] === "a" || got[0] === "b").toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
151
167
|
test("broadcast reaches every subscriber", async () => {
|
|
152
168
|
const bcast = signal("news", { delivery: "broadcast" });
|
|
153
169
|
const bus = await openBus(driver, [bcast], setup?.() ?? {});
|
|
@@ -262,7 +278,7 @@ describe("postgres transactional emit (dual-write fix)", () => {
|
|
|
262
278
|
describe("redis / nats outbox relay", () => {
|
|
263
279
|
test("redis relay publishes after commit, not on rollback", async () => {
|
|
264
280
|
const redis = createSignalRedisFake();
|
|
265
|
-
const once = signal("order-placed", { delivery: "once" });
|
|
281
|
+
const once = signal("order-placed", { delivery: "once", optional: true });
|
|
266
282
|
const bus = await openBus(redisSignalDriver, [once], { redis });
|
|
267
283
|
|
|
268
284
|
const tx = await bus.begin();
|
|
@@ -280,7 +296,7 @@ describe("redis / nats outbox relay", () => {
|
|
|
280
296
|
|
|
281
297
|
test("nats relay publishes after commit, not on rollback", async () => {
|
|
282
298
|
const nats = createSignalNatsFake();
|
|
283
|
-
const once = signal("order-placed", { delivery: "once" });
|
|
299
|
+
const once = signal("order-placed", { delivery: "once", optional: true });
|
|
284
300
|
const bus = await openBus(natsSignalDriver, [once], { nats });
|
|
285
301
|
|
|
286
302
|
const tx = await bus.begin();
|
|
@@ -347,7 +363,7 @@ describe("chaos — kill process mid-transaction", () => {
|
|
|
347
363
|
import { memorySignalDriver } from ${JSON.stringify(join(import.meta.dir, "../drivers/signal-memory.ts"))};
|
|
348
364
|
import { signal } from ${JSON.stringify(join(import.meta.dir, "signal/declare.ts"))};
|
|
349
365
|
import { createSignalRuntime } from ${JSON.stringify(join(import.meta.dir, "signal/runtime.ts"))};
|
|
350
|
-
const orderPlaced = signal("order-placed", { delivery: "once", retries: 3, deadLetter: true });
|
|
366
|
+
const orderPlaced = signal("order-placed", { delivery: "once", retries: 3, deadLetter: true, optional: true });
|
|
351
367
|
const runtime = createSignalRuntime({ driver: memorySignalDriver, durablePath: ${JSON.stringify(durablePath)} });
|
|
352
368
|
runtime.register(orderPlaced);
|
|
353
369
|
const bus = await runtime.start();
|
|
@@ -433,6 +449,74 @@ describe("chaos — kill process mid-transaction", () => {
|
|
|
433
449
|
await rm(dir, { recursive: true, force: true });
|
|
434
450
|
}
|
|
435
451
|
});
|
|
452
|
+
|
|
453
|
+
test("SIGKILL after claim: committed message is reclaimed after lease expiry", async () => {
|
|
454
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-signal-chaos-consume-"));
|
|
455
|
+
const durablePath = join(dir, "bus.json");
|
|
456
|
+
const markerPath = join(dir, "claimed");
|
|
457
|
+
const leaseMs = 80;
|
|
458
|
+
try {
|
|
459
|
+
const commit = Bun.spawn({
|
|
460
|
+
cmd: ["bun", join(import.meta.dir, "signal/chaos-child.ts"), durablePath, "commit"],
|
|
461
|
+
stdout: "pipe",
|
|
462
|
+
stderr: "pipe",
|
|
463
|
+
});
|
|
464
|
+
expect(await commit.exited).toBe(0);
|
|
465
|
+
|
|
466
|
+
const consumer = Bun.spawn({
|
|
467
|
+
cmd: [
|
|
468
|
+
"bun",
|
|
469
|
+
join(import.meta.dir, "signal/chaos-child.ts"),
|
|
470
|
+
durablePath,
|
|
471
|
+
"consume-hang",
|
|
472
|
+
markerPath,
|
|
473
|
+
String(leaseMs),
|
|
474
|
+
],
|
|
475
|
+
stdout: "pipe",
|
|
476
|
+
stderr: "pipe",
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
for (let i = 0; i < 100; i++) {
|
|
480
|
+
if (await Bun.file(markerPath).exists()) break;
|
|
481
|
+
await Bun.sleep(10);
|
|
482
|
+
}
|
|
483
|
+
expect(await Bun.file(markerPath).exists()).toBe(true);
|
|
484
|
+
consumer.kill(9);
|
|
485
|
+
await consumer.exited;
|
|
486
|
+
|
|
487
|
+
// Advance past the visibility lease so reclaim-at-claim can take the row.
|
|
488
|
+
await Bun.sleep(leaseMs + 40);
|
|
489
|
+
|
|
490
|
+
let clock = Date.now() + leaseMs + 1_000;
|
|
491
|
+
const orderPlaced = signal("order-placed", {
|
|
492
|
+
delivery: "once",
|
|
493
|
+
retries: 3,
|
|
494
|
+
deadLetter: true,
|
|
495
|
+
});
|
|
496
|
+
const runtime = createSignalRuntime({
|
|
497
|
+
driver: memorySignalDriver,
|
|
498
|
+
durablePath,
|
|
499
|
+
now: () => clock,
|
|
500
|
+
leaseMs,
|
|
501
|
+
});
|
|
502
|
+
runtime.register(orderPlaced);
|
|
503
|
+
const bus = await runtime.start();
|
|
504
|
+
openBuses.push(bus);
|
|
505
|
+
|
|
506
|
+
const seen: Array<{ payload: unknown; attempts: number }> = [];
|
|
507
|
+
await bus.subscribe("order-placed", "rescue", async (m) => {
|
|
508
|
+
seen.push({ payload: m.payload, attempts: m.attempts });
|
|
509
|
+
});
|
|
510
|
+
await bus.drain();
|
|
511
|
+
|
|
512
|
+
expect(seen).toHaveLength(1);
|
|
513
|
+
expect(seen[0]!.payload).toEqual({ id: "1" });
|
|
514
|
+
// Original claim in the killed child + reclaim here.
|
|
515
|
+
expect(seen[0]!.attempts).toBeGreaterThanOrEqual(2);
|
|
516
|
+
} finally {
|
|
517
|
+
await rm(dir, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
436
520
|
});
|
|
437
521
|
|
|
438
522
|
describe("protocol naming", () => {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-gated files handle for `fx.store` — keeps Bun.Image pipeline
|
|
3
|
+
* code in the store element so the kernel edge profile stays lean.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { StoreDecl } from "./declare.ts";
|
|
7
|
+
import {
|
|
8
|
+
createFilesImagePipeline,
|
|
9
|
+
putImageToBucket,
|
|
10
|
+
type FilesImageOptions,
|
|
11
|
+
type PutImageOptions,
|
|
12
|
+
} from "./files-image.ts";
|
|
13
|
+
import type { FilesStoreFxHandle } from "./runtime.ts";
|
|
14
|
+
|
|
15
|
+
/** Gate + dry-run hooks supplied by `createFxContext`. */
|
|
16
|
+
export interface GatedFilesFxBridge {
|
|
17
|
+
/**
|
|
18
|
+
* Capability-check + ledger one effect, then run `body`.
|
|
19
|
+
*
|
|
20
|
+
* @param kind - Effect kind
|
|
21
|
+
* @param resource - Resource ref
|
|
22
|
+
* @param body - Work to run under the gate
|
|
23
|
+
*/
|
|
24
|
+
gate<T>(kind: "read" | "write", resource: string, body: () => Promise<T>): Promise<T>;
|
|
25
|
+
/** Throw when a driver-backed write cannot be isolated in dry-run. */
|
|
26
|
+
refuseDryRunWrite(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lazy, capability-gated files handle — `image()` stays sync for chaining;
|
|
31
|
+
* terminals and CRUD record read/write effects.
|
|
32
|
+
*
|
|
33
|
+
* @param decl - Files store declaration
|
|
34
|
+
* @param open - Opens (and caches) the runtime handle
|
|
35
|
+
* @param bridge - fx gate + dry-run hooks
|
|
36
|
+
*/
|
|
37
|
+
export function createGatedFilesStoreHandle(
|
|
38
|
+
decl: StoreDecl,
|
|
39
|
+
open: () => Promise<FilesStoreFxHandle>,
|
|
40
|
+
bridge: GatedFilesFxBridge,
|
|
41
|
+
): FilesStoreFxHandle {
|
|
42
|
+
const ref = decl.ref as `files:${string}`;
|
|
43
|
+
let cached: FilesStoreFxHandle | undefined;
|
|
44
|
+
const ensure = async (): Promise<FilesStoreFxHandle> => {
|
|
45
|
+
if (!cached) cached = await open();
|
|
46
|
+
return cached;
|
|
47
|
+
};
|
|
48
|
+
const access = {
|
|
49
|
+
get: (key: string) =>
|
|
50
|
+
bridge.gate("read", ref, async () => {
|
|
51
|
+
const h = await ensure();
|
|
52
|
+
return h.get(key);
|
|
53
|
+
}),
|
|
54
|
+
put: (key: string, data: Uint8Array | string) =>
|
|
55
|
+
bridge.gate("write", ref, async () => {
|
|
56
|
+
bridge.refuseDryRunWrite();
|
|
57
|
+
const h = await ensure();
|
|
58
|
+
return h.put(key, data);
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ref,
|
|
64
|
+
get driverId() {
|
|
65
|
+
return cached?.driverId ?? "memory";
|
|
66
|
+
},
|
|
67
|
+
get(key) {
|
|
68
|
+
return access.get(key);
|
|
69
|
+
},
|
|
70
|
+
list(prefix) {
|
|
71
|
+
return bridge.gate("read", ref, async () => {
|
|
72
|
+
const h = await ensure();
|
|
73
|
+
return h.list(prefix);
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
put(key, data) {
|
|
77
|
+
return access.put(key, data);
|
|
78
|
+
},
|
|
79
|
+
delete(key) {
|
|
80
|
+
return bridge.gate("write", ref, async () => {
|
|
81
|
+
bridge.refuseDryRunWrite();
|
|
82
|
+
const h = await ensure();
|
|
83
|
+
return h.delete(key);
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
image(source: string | Uint8Array, imageOpts?: FilesImageOptions) {
|
|
87
|
+
return createFilesImagePipeline(access, source, imageOpts);
|
|
88
|
+
},
|
|
89
|
+
putImage(key: string, data: Uint8Array | string, putOpts?: PutImageOptions) {
|
|
90
|
+
return bridge.gate("write", ref, async () => {
|
|
91
|
+
bridge.refuseDryRunWrite();
|
|
92
|
+
// putImage writes original + variants via bucket put; use ungated
|
|
93
|
+
// runtime puts inside the single write effect.
|
|
94
|
+
const h = await ensure();
|
|
95
|
+
return putImageToBucket(
|
|
96
|
+
{ get: (k) => h.get(k), put: (k, d) => h.put(k, d) },
|
|
97
|
+
key,
|
|
98
|
+
data,
|
|
99
|
+
putOpts,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|