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.
- package/CLAUDE.md +26 -0
- package/core/App.ts +97 -0
- package/core/remote/CircuitBreaker.ts +115 -0
- package/core/remote/OutboxWorker.ts +176 -0
- package/core/remote/RemoteManager.ts +400 -0
- package/core/remote/RpcCaller.ts +310 -0
- package/core/remote/StreamConsumer.ts +535 -0
- package/core/remote/decorators.ts +121 -0
- package/core/remote/health.ts +139 -0
- package/core/remote/index.ts +37 -0
- package/core/remote/metrics.ts +99 -0
- package/core/remote/outboxSchema.ts +41 -0
- package/core/remote/types.ts +151 -0
- package/core/scheduler/DistributedLock.ts +309 -266
- package/docs/SCALABILITY_PLAN.md +3 -3
- package/package.json +1 -1
- package/query/FilterBuilder.ts +25 -0
- package/query/Query.ts +5 -1
- package/query/builders/JsonbArrayBuilder.ts +116 -0
- package/query/index.ts +28 -2
- package/tests/helpers/MockRedisClient.ts +113 -0
- package/tests/helpers/MockRedisStreamServer.ts +448 -0
- package/tests/integration/query/Query.exec.test.ts +67 -14
- package/tests/integration/query/Query.jsonbArray.test.ts +214 -0
- package/tests/integration/remote/dlq.test.ts +175 -0
- package/tests/integration/remote/event-dispatch.test.ts +114 -0
- package/tests/integration/remote/outbox.test.ts +130 -0
- package/tests/integration/remote/rpc.test.ts +177 -0
- package/tests/pglite-setup.ts +1 -0
- package/tests/unit/query/JsonbArrayBuilder.test.ts +178 -0
- package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
- package/tests/unit/remote/RemoteError.test.ts +55 -0
- package/tests/unit/remote/decorators.test.ts +195 -0
- package/tests/unit/remote/metrics.test.ts +115 -0
- 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
|
+
});
|
package/tests/pglite-setup.ts
CHANGED
|
@@ -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
|
+
});
|