bunsane 0.2.8 → 0.2.10

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 (35) hide show
  1. package/CLAUDE.md +26 -0
  2. package/core/App.ts +97 -0
  3. package/core/remote/CircuitBreaker.ts +115 -0
  4. package/core/remote/OutboxWorker.ts +176 -0
  5. package/core/remote/RemoteManager.ts +400 -0
  6. package/core/remote/RpcCaller.ts +310 -0
  7. package/core/remote/StreamConsumer.ts +535 -0
  8. package/core/remote/decorators.ts +121 -0
  9. package/core/remote/health.ts +139 -0
  10. package/core/remote/index.ts +37 -0
  11. package/core/remote/metrics.ts +99 -0
  12. package/core/remote/outboxSchema.ts +41 -0
  13. package/core/remote/types.ts +151 -0
  14. package/core/scheduler/DistributedLock.ts +309 -266
  15. package/docs/SCALABILITY_PLAN.md +3 -3
  16. package/package.json +1 -1
  17. package/query/FilterBuilder.ts +25 -0
  18. package/query/Query.ts +5 -1
  19. package/query/builders/JsonbArrayBuilder.ts +116 -0
  20. package/query/index.ts +28 -2
  21. package/tests/helpers/MockRedisClient.ts +113 -0
  22. package/tests/helpers/MockRedisStreamServer.ts +448 -0
  23. package/tests/integration/query/Query.exec.test.ts +67 -14
  24. package/tests/integration/query/Query.jsonbArray.test.ts +214 -0
  25. package/tests/integration/remote/dlq.test.ts +175 -0
  26. package/tests/integration/remote/event-dispatch.test.ts +114 -0
  27. package/tests/integration/remote/outbox.test.ts +130 -0
  28. package/tests/integration/remote/rpc.test.ts +177 -0
  29. package/tests/pglite-setup.ts +1 -0
  30. package/tests/unit/query/JsonbArrayBuilder.test.ts +178 -0
  31. package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
  32. package/tests/unit/remote/RemoteError.test.ts +55 -0
  33. package/tests/unit/remote/decorators.test.ts +195 -0
  34. package/tests/unit/remote/metrics.test.ts +115 -0
  35. package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
@@ -0,0 +1,130 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { RemoteManager } from "../../../core/remote/RemoteManager";
3
+ import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
4
+ import { createMockRedisFactory } from "../../helpers/MockRedisClient";
5
+ import db from "../../../database";
6
+
7
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ describe("Transactional Outbox", () => {
10
+ let server: MockRedisStreamServer;
11
+ let app: RemoteManager;
12
+
13
+ beforeEach(async () => {
14
+ // Clear any residual outbox rows from earlier tests
15
+ try {
16
+ await db`DELETE FROM remote_outbox`;
17
+ } catch {
18
+ /* table may not exist yet */
19
+ }
20
+ server = new MockRedisStreamServer();
21
+ app = new RemoteManager({
22
+ appName: "app",
23
+ redisFactory: createMockRedisFactory(server),
24
+ blockMs: 30,
25
+ autoClaimIdleMs: 0,
26
+ dlqMaxDeliveries: 0,
27
+ shutdownDrainMs: 100,
28
+ enableOutbox: true,
29
+ outboxPollIntervalMs: 50,
30
+ outboxBatchSize: 10,
31
+ });
32
+ await app.start();
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await app.shutdown();
37
+ });
38
+
39
+ test("emit({ trx }) inserts outbox row within transaction", async () => {
40
+ await (db as any).begin(async (trx: any) => {
41
+ const id = await app.emit(
42
+ "downstream",
43
+ "order.created",
44
+ { orderId: "abc" },
45
+ { trx }
46
+ );
47
+ expect(id).toBeDefined();
48
+ });
49
+
50
+ const rows = await db`
51
+ SELECT target, event, data, published_at
52
+ FROM remote_outbox
53
+ `;
54
+ expect(rows.length).toBe(1);
55
+ expect(rows[0]!.target).toBe("downstream");
56
+ expect(rows[0]!.event).toBe("order.created");
57
+ });
58
+
59
+ test("worker publishes committed rows to Redis + marks published_at", async () => {
60
+ await (db as any).begin(async (trx: any) => {
61
+ await app.emit(
62
+ "downstream",
63
+ "published.event",
64
+ { n: 1 },
65
+ { trx }
66
+ );
67
+ });
68
+ await wait(200); // allow at least one poll tick
69
+
70
+ // Row should now be marked published
71
+ const rows = await db`
72
+ SELECT published_at FROM remote_outbox
73
+ WHERE event = 'published.event'
74
+ `;
75
+ expect(rows[0]!.published_at).not.toBeNull();
76
+
77
+ // Stream should have the message
78
+ expect(server.getStreamLength("remote:downstream")).toBeGreaterThanOrEqual(
79
+ 1
80
+ );
81
+ });
82
+
83
+ test("rolled-back transaction suppresses publish", async () => {
84
+ try {
85
+ await (db as any).begin(async (trx: any) => {
86
+ await app.emit(
87
+ "downstream",
88
+ "rolled.back",
89
+ { n: 2 },
90
+ { trx }
91
+ );
92
+ throw new Error("force rollback");
93
+ });
94
+ } catch {
95
+ /* expected */
96
+ }
97
+ await wait(200);
98
+
99
+ const rows = await db`
100
+ SELECT COUNT(*)::int AS c FROM remote_outbox
101
+ WHERE event = 'rolled.back'
102
+ `;
103
+ expect(rows[0]!.c).toBe(0);
104
+ expect(server.getStreamLength("remote:downstream")).toBe(0);
105
+ });
106
+
107
+ test("direct emit + outbox emit hit same target stream", async () => {
108
+ await app.emit("other", "direct", { n: "a" });
109
+ await (db as any).begin(async (trx: any) => {
110
+ await app.emit("other", "outbox", { n: "b" }, { trx });
111
+ });
112
+ await wait(200);
113
+
114
+ const entries = server.xrange("remote:other", "-", "+");
115
+ expect(entries.length).toBe(2);
116
+ });
117
+
118
+ test("metrics count emit.direct vs emit.outbox separately", async () => {
119
+ await app.emit("dst", "direct", {});
120
+ await (db as any).begin(async (trx: any) => {
121
+ await app.emit("dst", "outbox", {}, { trx });
122
+ });
123
+ await wait(150);
124
+
125
+ const snap = app.getMetrics();
126
+ expect(snap.emit.direct).toBe(1);
127
+ expect(snap.emit.outbox).toBe(1);
128
+ expect(snap.outbox.published).toBeGreaterThanOrEqual(1);
129
+ });
130
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { RemoteManager } from "../../../core/remote/RemoteManager";
3
+ import { RemoteError } from "../../../core/remote/types";
4
+ import { MockRedisStreamServer } from "../../helpers/MockRedisStreamServer";
5
+ import { createMockRedisFactory } from "../../helpers/MockRedisClient";
6
+
7
+ describe("RPC round-trip", () => {
8
+ let server: MockRedisStreamServer;
9
+ let client: RemoteManager;
10
+ let server_app: RemoteManager;
11
+
12
+ beforeEach(async () => {
13
+ server = new MockRedisStreamServer();
14
+ client = new RemoteManager({
15
+ appName: "client-app",
16
+ redisFactory: createMockRedisFactory(server),
17
+ blockMs: 50,
18
+ autoClaimIdleMs: 0,
19
+ dlqMaxDeliveries: 0,
20
+ shutdownDrainMs: 100,
21
+ defaultCallTimeout: 1000,
22
+ });
23
+ server_app = new RemoteManager({
24
+ appName: "server-app",
25
+ redisFactory: createMockRedisFactory(server),
26
+ blockMs: 50,
27
+ autoClaimIdleMs: 0,
28
+ dlqMaxDeliveries: 0,
29
+ shutdownDrainMs: 100,
30
+ });
31
+ await client.start();
32
+ await server_app.start();
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await client.shutdown();
37
+ await server_app.shutdown();
38
+ });
39
+
40
+ test("call() returns handler result", async () => {
41
+ server_app.onRpc(
42
+ "order.get",
43
+ async (data: any) => ({ id: data.id, status: "ok" }),
44
+ "h1"
45
+ );
46
+ const result = await client.call<{ id: string; status: string }>(
47
+ "server-app",
48
+ "order.get",
49
+ { id: "abc" }
50
+ );
51
+ expect(result).toEqual({ id: "abc", status: "ok" });
52
+ });
53
+
54
+ test("call() rejects with TIMEOUT when no handler registered", async () => {
55
+ // server_app has no handler — still returns NOT_FOUND, not TIMEOUT
56
+ try {
57
+ await client.call(
58
+ "server-app",
59
+ "nonexistent.method",
60
+ {},
61
+ { timeout: 500 }
62
+ );
63
+ throw new Error("expected throw");
64
+ } catch (err) {
65
+ expect(err).toBeInstanceOf(RemoteError);
66
+ expect((err as RemoteError).code).toBe("NOT_FOUND");
67
+ }
68
+ });
69
+
70
+ test("call() rejects with TIMEOUT when target app down", async () => {
71
+ // Kill server_app — no consumer for the request stream
72
+ await server_app.shutdown();
73
+ try {
74
+ await client.call(
75
+ "server-app",
76
+ "anything",
77
+ {},
78
+ { timeout: 200 }
79
+ );
80
+ throw new Error("expected throw");
81
+ } catch (err) {
82
+ expect(err).toBeInstanceOf(RemoteError);
83
+ expect((err as RemoteError).code).toBe("TIMEOUT");
84
+ }
85
+ });
86
+
87
+ test("call() to broadcast target * rejects INVALID_TARGET", async () => {
88
+ try {
89
+ await client.call("*", "anything", {});
90
+ throw new Error("expected throw");
91
+ } catch (err) {
92
+ expect(err).toBeInstanceOf(RemoteError);
93
+ expect((err as RemoteError).code).toBe("INVALID_TARGET");
94
+ }
95
+ });
96
+
97
+ test("handler exception propagates as HANDLER_ERROR", async () => {
98
+ server_app.onRpc(
99
+ "fail",
100
+ async () => {
101
+ throw new Error("something bad");
102
+ },
103
+ "h1"
104
+ );
105
+ try {
106
+ await client.call("server-app", "fail", {});
107
+ throw new Error("expected throw");
108
+ } catch (err) {
109
+ expect(err).toBeInstanceOf(RemoteError);
110
+ expect((err as RemoteError).code).toBe("HANDLER_ERROR");
111
+ }
112
+ });
113
+
114
+ test("handler custom RemoteError code flows through", async () => {
115
+ server_app.onRpc(
116
+ "forbidden",
117
+ async () => {
118
+ throw new RemoteError("no", {
119
+ code: "FORBIDDEN",
120
+ extensions: { reason: "test" },
121
+ });
122
+ },
123
+ "h1"
124
+ );
125
+ try {
126
+ await client.call("server-app", "forbidden", {});
127
+ throw new Error("expected throw");
128
+ } catch (err) {
129
+ expect(err).toBeInstanceOf(RemoteError);
130
+ const re = err as RemoteError;
131
+ expect(re.code).toBe("FORBIDDEN");
132
+ expect(re.extensions).toEqual({ reason: "test" });
133
+ }
134
+ });
135
+
136
+ test("ctx carries correlationId + deadline", async () => {
137
+ let captured: any = null;
138
+ server_app.onRpc(
139
+ "ctx-check",
140
+ async (_data, ctx) => {
141
+ captured = {
142
+ correlationId: ctx.correlationId,
143
+ deadline: ctx.deadline,
144
+ sourceApp: ctx.sourceApp,
145
+ };
146
+ return null;
147
+ },
148
+ "h1"
149
+ );
150
+ await client.call("server-app", "ctx-check", {}, { timeout: 2000 });
151
+ expect(captured.correlationId).toMatch(/^[0-9a-f-]{36}$/);
152
+ expect(captured.deadline).toBeInstanceOf(Date);
153
+ expect(captured.sourceApp).toBe("client-app");
154
+ });
155
+
156
+ test("metrics track successful + failed RPCs", async () => {
157
+ server_app.onRpc("ok", async () => "x", "h1");
158
+ server_app.onRpc(
159
+ "bad",
160
+ async () => {
161
+ throw new Error("x");
162
+ },
163
+ "h2"
164
+ );
165
+ await client.call("server-app", "ok", {});
166
+ await client.call("server-app", "bad", {}).catch(() => {});
167
+
168
+ const clientSnap = client.getMetrics();
169
+ expect(clientSnap.rpc.called).toBe(2);
170
+ expect(clientSnap.rpc.succeeded).toBe(1);
171
+ expect(clientSnap.rpc.failed).toBe(1);
172
+
173
+ const serverSnap = server_app.getMetrics();
174
+ expect(serverSnap.rpc.handlerExecuted).toBe(1);
175
+ expect(serverSnap.rpc.handlerFailed).toBe(1);
176
+ });
177
+ });
@@ -35,6 +35,7 @@ const proc = spawn('bun', ['test', ...testDirs], {
35
35
  env: {
36
36
  ...process.env,
37
37
  USE_PGLITE: 'true',
38
+ DB_CONNECTION_URL: '', // Clear to use POSTGRES_* vars
38
39
  POSTGRES_HOST: 'localhost',
39
40
  POSTGRES_PORT: String(PORT),
40
41
  POSTGRES_USER: 'postgres',
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Unit tests for JSONB Array Filter Builders
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { buildJSONBPath } from '../../../query/FilterBuilder';
6
+ import { QueryContext } from '../../../query/QueryContext';
7
+ import {
8
+ jsonbContainsBuilder,
9
+ jsonbContainedByBuilder,
10
+ jsonbHasAnyBuilder,
11
+ jsonbHasAllBuilder,
12
+ jsonbArrayOptions,
13
+ } from '../../../query/builders/JsonbArrayBuilder';
14
+
15
+ describe('buildJSONBPath', () => {
16
+ test('simple field returns JSONB node path', () => {
17
+ expect(buildJSONBPath('tags', 'c')).toBe("c.data->'tags'");
18
+ });
19
+
20
+ test('nested field returns JSONB node path', () => {
21
+ expect(buildJSONBPath('metadata.tags', 'c')).toBe("c.data->'metadata'->'tags'");
22
+ });
23
+
24
+ test('deeply nested field', () => {
25
+ expect(buildJSONBPath('a.b.c', 'c')).toBe("c.data->'a'->'b'->'c'");
26
+ });
27
+
28
+ test('uses provided alias', () => {
29
+ expect(buildJSONBPath('tags', 'comp')).toBe("comp.data->'tags'");
30
+ });
31
+ });
32
+
33
+ describe('jsonbContainsBuilder (@>)', () => {
34
+ test('single string value is auto-wrapped in array', () => {
35
+ const ctx = new QueryContext();
36
+ const result = jsonbContainsBuilder(
37
+ { field: 'tags', operator: 'CONTAINS', value: 'urgent' },
38
+ 'c', ctx
39
+ );
40
+ expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
41
+ expect(ctx.params[0]).toEqual(['urgent']);
42
+ expect(result.addedParams).toBe(1);
43
+ });
44
+
45
+ test('array value is passed as raw array', () => {
46
+ const ctx = new QueryContext();
47
+ const result = jsonbContainsBuilder(
48
+ { field: 'tags', operator: 'CONTAINS', value: ['a', 'b'] },
49
+ 'c', ctx
50
+ );
51
+ expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
52
+ expect(ctx.params[0]).toEqual(['a', 'b']);
53
+ });
54
+
55
+ test('nested field path', () => {
56
+ const ctx = new QueryContext();
57
+ const result = jsonbContainsBuilder(
58
+ { field: 'meta.tags', operator: 'CONTAINS', value: 'x' },
59
+ 'c', ctx
60
+ );
61
+ expect(result.sql).toBe("c.data->'meta'->'tags' @> $1::jsonb");
62
+ });
63
+
64
+ test('numeric value', () => {
65
+ const ctx = new QueryContext();
66
+ jsonbContainsBuilder(
67
+ { field: 'scores', operator: 'CONTAINS', value: 42 },
68
+ 'c', ctx
69
+ );
70
+ expect(ctx.params[0]).toEqual([42]);
71
+ });
72
+ });
73
+
74
+ describe('jsonbContainedByBuilder (<@)', () => {
75
+ test('generates correct SQL', () => {
76
+ const ctx = new QueryContext();
77
+ const result = jsonbContainedByBuilder(
78
+ { field: 'tags', operator: 'CONTAINED_BY', value: ['a', 'b', 'c'] },
79
+ 'c', ctx
80
+ );
81
+ expect(result.sql).toBe("c.data->'tags' <@ $1::jsonb");
82
+ expect(ctx.params[0]).toEqual(['a', 'b', 'c']);
83
+ expect(result.addedParams).toBe(1);
84
+ });
85
+
86
+ test('single value is auto-wrapped', () => {
87
+ const ctx = new QueryContext();
88
+ jsonbContainedByBuilder(
89
+ { field: 'tags', operator: 'CONTAINED_BY', value: 'only' },
90
+ 'c', ctx
91
+ );
92
+ expect(ctx.params[0]).toEqual(['only']);
93
+ });
94
+ });
95
+
96
+ describe('jsonbHasAnyBuilder (?|)', () => {
97
+ test('generates correct SQL with text[] cast', () => {
98
+ const ctx = new QueryContext();
99
+ const result = jsonbHasAnyBuilder(
100
+ { field: 'tags', operator: 'HAS_ANY', value: ['a', 'b'] },
101
+ 'c', ctx
102
+ );
103
+ expect(result.sql).toBe("c.data->'tags' ?| $1::text[]");
104
+ expect(ctx.params[0]).toEqual(['a', 'b']);
105
+ expect(result.addedParams).toBe(1);
106
+ });
107
+
108
+ test('single value is auto-wrapped and stringified', () => {
109
+ const ctx = new QueryContext();
110
+ jsonbHasAnyBuilder(
111
+ { field: 'tags', operator: 'HAS_ANY', value: 'solo' },
112
+ 'c', ctx
113
+ );
114
+ expect(ctx.params[0]).toEqual(['solo']);
115
+ });
116
+
117
+ test('numeric values are cast to strings', () => {
118
+ const ctx = new QueryContext();
119
+ jsonbHasAnyBuilder(
120
+ { field: 'ids', operator: 'HAS_ANY', value: [1, 2, 3] },
121
+ 'c', ctx
122
+ );
123
+ expect(ctx.params[0]).toEqual(['1', '2', '3']);
124
+ });
125
+ });
126
+
127
+ describe('jsonbHasAllBuilder (?&)', () => {
128
+ test('generates correct SQL with text[] cast', () => {
129
+ const ctx = new QueryContext();
130
+ const result = jsonbHasAllBuilder(
131
+ { field: 'tags', operator: 'HAS_ALL', value: ['x', 'y'] },
132
+ 'c', ctx
133
+ );
134
+ expect(result.sql).toBe("c.data->'tags' ?& $1::text[]");
135
+ expect(ctx.params[0]).toEqual(['x', 'y']);
136
+ expect(result.addedParams).toBe(1);
137
+ });
138
+ });
139
+
140
+ describe('validation', () => {
141
+ const validate = jsonbArrayOptions.validate!;
142
+
143
+ test('rejects null value', () => {
144
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: null })).toBe(false);
145
+ });
146
+
147
+ test('rejects undefined value', () => {
148
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: undefined })).toBe(false);
149
+ });
150
+
151
+ test('rejects empty array', () => {
152
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: [] })).toBe(false);
153
+ });
154
+
155
+ test('accepts string value', () => {
156
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: 'tag' })).toBe(true);
157
+ });
158
+
159
+ test('accepts number value', () => {
160
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: 42 })).toBe(true);
161
+ });
162
+
163
+ test('accepts boolean value', () => {
164
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: true })).toBe(true);
165
+ });
166
+
167
+ test('accepts array of strings', () => {
168
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: ['a', 'b'] })).toBe(true);
169
+ });
170
+
171
+ test('rejects object value', () => {
172
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: { key: 'val' } })).toBe(false);
173
+ });
174
+
175
+ test('rejects array with non-primitive elements', () => {
176
+ expect(validate({ field: 'f', operator: 'CONTAINS', value: [{ a: 1 }] })).toBe(false);
177
+ });
178
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ CircuitBreaker,
4
+ CircuitOpenError,
5
+ } from "../../../core/remote/CircuitBreaker";
6
+
7
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ describe("CircuitBreaker", () => {
10
+ describe("state transitions", () => {
11
+ test("starts closed", () => {
12
+ const cb = new CircuitBreaker();
13
+ expect(cb.getState()).toBe("closed");
14
+ });
15
+
16
+ test("stays closed below threshold", () => {
17
+ const cb = new CircuitBreaker({ threshold: 3 });
18
+ cb.recordFailure();
19
+ cb.recordFailure();
20
+ expect(cb.getState()).toBe("closed");
21
+ });
22
+
23
+ test("opens at threshold", () => {
24
+ const cb = new CircuitBreaker({ threshold: 3 });
25
+ cb.recordFailure();
26
+ cb.recordFailure();
27
+ cb.recordFailure();
28
+ expect(cb.getState()).toBe("open");
29
+ });
30
+
31
+ test("transitions to half-open after reset window", async () => {
32
+ const cb = new CircuitBreaker({ threshold: 1, resetTimeoutMs: 50 });
33
+ cb.recordFailure();
34
+ expect(cb.getState()).toBe("open");
35
+ await sleep(60);
36
+ expect(cb.getState()).toBe("half-open");
37
+ });
38
+
39
+ test("half-open success closes breaker", async () => {
40
+ const cb = new CircuitBreaker({ threshold: 1, resetTimeoutMs: 50 });
41
+ cb.recordFailure();
42
+ await sleep(60);
43
+ expect(cb.getState()).toBe("half-open");
44
+ cb.recordSuccess();
45
+ expect(cb.getState()).toBe("closed");
46
+ });
47
+
48
+ test("half-open failure reopens breaker", async () => {
49
+ const cb = new CircuitBreaker({ threshold: 1, resetTimeoutMs: 50 });
50
+ cb.recordFailure();
51
+ await sleep(60);
52
+ expect(cb.getState()).toBe("half-open");
53
+ cb.recordFailure();
54
+ expect(cb.getState()).toBe("open");
55
+ });
56
+
57
+ test("success in closed state zeroes failure count", () => {
58
+ const cb = new CircuitBreaker({ threshold: 3 });
59
+ cb.recordFailure();
60
+ cb.recordFailure();
61
+ cb.recordSuccess();
62
+ cb.recordFailure();
63
+ cb.recordFailure();
64
+ // Still closed — counter reset to 0 on success, only 2 new failures
65
+ expect(cb.getState()).toBe("closed");
66
+ });
67
+ });
68
+
69
+ describe("exec()", () => {
70
+ test("passes result on success", async () => {
71
+ const cb = new CircuitBreaker();
72
+ const result = await cb.exec(async () => 42);
73
+ expect(result).toBe(42);
74
+ });
75
+
76
+ test("records failure on thrown error", async () => {
77
+ const cb = new CircuitBreaker({ threshold: 2 });
78
+ await expect(
79
+ cb.exec(async () => {
80
+ throw new Error("boom");
81
+ })
82
+ ).rejects.toThrow("boom");
83
+ expect(cb.getStats().failures).toBe(1);
84
+ });
85
+
86
+ test("rejects immediately when open", async () => {
87
+ const cb = new CircuitBreaker({ threshold: 1 });
88
+ await expect(
89
+ cb.exec(async () => {
90
+ throw new Error("fail");
91
+ })
92
+ ).rejects.toThrow();
93
+ // Now open
94
+ await expect(cb.exec(async () => "should not run")).rejects.toBeInstanceOf(
95
+ CircuitOpenError
96
+ );
97
+ });
98
+
99
+ test("open-state rejection does not call fn", async () => {
100
+ const cb = new CircuitBreaker({ threshold: 1 });
101
+ await cb.exec(async () => { throw new Error("x"); }).catch(() => {});
102
+ let called = false;
103
+ await cb.exec(async () => { called = true; }).catch(() => {});
104
+ expect(called).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("hooks", () => {
109
+ test("onTrip fires once when opening", () => {
110
+ const cb = new CircuitBreaker({ threshold: 2 });
111
+ let trips = 0;
112
+ cb.onTrip = () => trips++;
113
+ cb.recordFailure();
114
+ expect(trips).toBe(0);
115
+ cb.recordFailure();
116
+ expect(trips).toBe(1);
117
+ });
118
+
119
+ test("onTrip fires again on half-open→open transition", async () => {
120
+ const cb = new CircuitBreaker({ threshold: 1, resetTimeoutMs: 30 });
121
+ let trips = 0;
122
+ cb.onTrip = () => trips++;
123
+ cb.recordFailure();
124
+ expect(trips).toBe(1);
125
+ await sleep(40);
126
+ // half-open trial fails
127
+ cb.recordFailure();
128
+ expect(trips).toBe(2);
129
+ });
130
+
131
+ test("onReject fires when exec rejected by open breaker", async () => {
132
+ const cb = new CircuitBreaker({ threshold: 1 });
133
+ let rejects = 0;
134
+ cb.onReject = () => rejects++;
135
+ await cb.exec(async () => { throw new Error("x"); }).catch(() => {});
136
+ await cb.exec(async () => 1).catch(() => {});
137
+ expect(rejects).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe("reset()", () => {
142
+ test("force-closes an open breaker", () => {
143
+ const cb = new CircuitBreaker({ threshold: 1 });
144
+ cb.recordFailure();
145
+ expect(cb.getState()).toBe("open");
146
+ cb.reset();
147
+ expect(cb.getState()).toBe("closed");
148
+ expect(cb.getStats().failures).toBe(0);
149
+ });
150
+ });
151
+
152
+ describe("CircuitOpenError", () => {
153
+ test("has CIRCUIT_OPEN code", () => {
154
+ const err = new CircuitOpenError();
155
+ expect(err.code).toBe("CIRCUIT_OPEN");
156
+ expect(err).toBeInstanceOf(Error);
157
+ });
158
+ });
159
+ });