@digitalpaws/starr 0.0.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/README.rst +75 -0
- package/data/.nfs.811bbb0d.43c34.4 +0 -0
- package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
- package/data/.nfs.82d127f7.fffff5704.4 +0 -0
- package/data/.nfs.82d127fe.fffff5704.4 +0 -0
- package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
- package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
- package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
- package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
- package/data/.nfs.82d18536.fffff5e24.4 +0 -0
- package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
- package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
- package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
- package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
- package/data/.nfs.8314122e.fffffb044.4 +0 -0
- package/data/.nfs.83141236.fffffb044.4 +0 -0
- package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
- package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
- package/data/logs/starr.log +66244 -0
- package/data/starr.db +0 -0
- package/data/starr.db-shm +0 -0
- package/data/starr.db-wal +0 -0
- package/data/starr.jwt +1 -0
- package/data/starr.key +1 -0
- package/dist/auth/auth-service.d.ts +42 -0
- package/dist/auth/auth-service.d.ts.map +1 -0
- package/dist/auth/auth-service.js +226 -0
- package/dist/auth/auth-service.js.map +1 -0
- package/dist/auth/http-server.d.ts +16 -0
- package/dist/auth/http-server.d.ts.map +1 -0
- package/dist/auth/http-server.js +207 -0
- package/dist/auth/http-server.js.map +1 -0
- package/dist/auth/jwt-secret.d.ts +6 -0
- package/dist/auth/jwt-secret.d.ts.map +1 -0
- package/dist/auth/jwt-secret.js +51 -0
- package/dist/auth/jwt-secret.js.map +1 -0
- package/dist/auth/oidc/oidc-flow.d.ts +60 -0
- package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-flow.js +201 -0
- package/dist/auth/oidc/oidc-flow.js.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.js +124 -0
- package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
- package/dist/auth/oidc/user-service.d.ts +67 -0
- package/dist/auth/oidc/user-service.d.ts.map +1 -0
- package/dist/auth/oidc/user-service.js +177 -0
- package/dist/auth/oidc/user-service.js.map +1 -0
- package/dist/auth/rate-limiter.d.ts +31 -0
- package/dist/auth/rate-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limiter.js +70 -0
- package/dist/auth/rate-limiter.js.map +1 -0
- package/dist/config/config-service.d.ts +83 -0
- package/dist/config/config-service.d.ts.map +1 -0
- package/dist/config/config-service.js +309 -0
- package/dist/config/config-service.js.map +1 -0
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +140 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/content-types/registry.d.ts +75 -0
- package/dist/content-types/registry.d.ts.map +1 -0
- package/dist/content-types/registry.js +171 -0
- package/dist/content-types/registry.js.map +1 -0
- package/dist/datastore/db.d.ts +9 -0
- package/dist/datastore/db.d.ts.map +1 -0
- package/dist/datastore/db.js +27 -0
- package/dist/datastore/db.js.map +1 -0
- package/dist/datastore/encrypt.d.ts +9 -0
- package/dist/datastore/encrypt.d.ts.map +1 -0
- package/dist/datastore/encrypt.js +84 -0
- package/dist/datastore/encrypt.js.map +1 -0
- package/dist/datastore/migrations.d.ts +10 -0
- package/dist/datastore/migrations.d.ts.map +1 -0
- package/dist/datastore/migrations.js +340 -0
- package/dist/datastore/migrations.js.map +1 -0
- package/dist/datastore/storage.d.ts +4 -0
- package/dist/datastore/storage.d.ts.map +1 -0
- package/dist/datastore/storage.js +60 -0
- package/dist/datastore/storage.js.map +1 -0
- package/dist/events/event-bus.d.ts +15 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +140 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/spine.d.ts +31 -0
- package/dist/events/spine.d.ts.map +1 -0
- package/dist/events/spine.js +68 -0
- package/dist/events/spine.js.map +1 -0
- package/dist/events/trace.d.ts +24 -0
- package/dist/events/trace.d.ts.map +1 -0
- package/dist/events/trace.js +33 -0
- package/dist/events/trace.js.map +1 -0
- package/dist/host.d.ts +65 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +94 -0
- package/dist/host.js.map +1 -0
- package/dist/library/importer.d.ts +29 -0
- package/dist/library/importer.d.ts.map +1 -0
- package/dist/library/importer.js +145 -0
- package/dist/library/importer.js.map +1 -0
- package/dist/library/library-cache.d.ts +46 -0
- package/dist/library/library-cache.d.ts.map +1 -0
- package/dist/library/library-cache.js +152 -0
- package/dist/library/library-cache.js.map +1 -0
- package/dist/library/library-config.d.ts +12 -0
- package/dist/library/library-config.d.ts.map +1 -0
- package/dist/library/library-config.js +10 -0
- package/dist/library/library-config.js.map +1 -0
- package/dist/library/library-scanner.d.ts +62 -0
- package/dist/library/library-scanner.d.ts.map +1 -0
- package/dist/library/library-scanner.js +336 -0
- package/dist/library/library-scanner.js.map +1 -0
- package/dist/library/library-service.d.ts +65 -0
- package/dist/library/library-service.d.ts.map +1 -0
- package/dist/library/library-service.js +197 -0
- package/dist/library/library-service.js.map +1 -0
- package/dist/library/metadata-matcher.d.ts +56 -0
- package/dist/library/metadata-matcher.d.ts.map +1 -0
- package/dist/library/metadata-matcher.js +207 -0
- package/dist/library/metadata-matcher.js.map +1 -0
- package/dist/library/naming.d.ts +8 -0
- package/dist/library/naming.d.ts.map +1 -0
- package/dist/library/naming.js +47 -0
- package/dist/library/naming.js.map +1 -0
- package/dist/library/scan-controller.d.ts +95 -0
- package/dist/library/scan-controller.d.ts.map +1 -0
- package/dist/library/scan-controller.js +379 -0
- package/dist/library/scan-controller.js.map +1 -0
- package/dist/library/source-resolver.d.ts +17 -0
- package/dist/library/source-resolver.d.ts.map +1 -0
- package/dist/library/source-resolver.js +75 -0
- package/dist/library/source-resolver.js.map +1 -0
- package/dist/logging/logger.d.ts +3 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +107 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/main.d.ts +36 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +229 -0
- package/dist/main.js.map +1 -0
- package/dist/plugins/context.d.ts +10 -0
- package/dist/plugins/context.d.ts.map +1 -0
- package/dist/plugins/context.js +110 -0
- package/dist/plugins/context.js.map +1 -0
- package/dist/plugins/discovery.d.ts +2 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +32 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +245 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/state/request-intake.d.ts +26 -0
- package/dist/state/request-intake.d.ts.map +1 -0
- package/dist/state/request-intake.js +74 -0
- package/dist/state/request-intake.js.map +1 -0
- package/dist/state/state-store.d.ts +146 -0
- package/dist/state/state-store.d.ts.map +1 -0
- package/dist/state/state-store.js +465 -0
- package/dist/state/state-store.js.map +1 -0
- package/dist/state/trace-store.d.ts +12 -0
- package/dist/state/trace-store.d.ts.map +1 -0
- package/dist/state/trace-store.js +24 -0
- package/dist/state/trace-store.js.map +1 -0
- package/dist/types/plugin.d.ts +93 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +4 -0
- package/dist/types/plugin.js.map +1 -0
- package/dist/web/index.d.ts +10 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +153 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/routes/activity.d.ts +6 -0
- package/dist/web/routes/activity.d.ts.map +1 -0
- package/dist/web/routes/activity.js +31 -0
- package/dist/web/routes/activity.js.map +1 -0
- package/dist/web/routes/auth.d.ts +6 -0
- package/dist/web/routes/auth.d.ts.map +1 -0
- package/dist/web/routes/auth.js +142 -0
- package/dist/web/routes/auth.js.map +1 -0
- package/dist/web/routes/config.d.ts +7 -0
- package/dist/web/routes/config.d.ts.map +1 -0
- package/dist/web/routes/config.js +204 -0
- package/dist/web/routes/config.js.map +1 -0
- package/dist/web/routes/content-types.d.ts +6 -0
- package/dist/web/routes/content-types.d.ts.map +1 -0
- package/dist/web/routes/content-types.js +29 -0
- package/dist/web/routes/content-types.js.map +1 -0
- package/dist/web/routes/discovery.d.ts +6 -0
- package/dist/web/routes/discovery.d.ts.map +1 -0
- package/dist/web/routes/discovery.js +111 -0
- package/dist/web/routes/discovery.js.map +1 -0
- package/dist/web/routes/library-import.d.ts +6 -0
- package/dist/web/routes/library-import.d.ts.map +1 -0
- package/dist/web/routes/library-import.js +120 -0
- package/dist/web/routes/library-import.js.map +1 -0
- package/dist/web/routes/library-management.d.ts +6 -0
- package/dist/web/routes/library-management.d.ts.map +1 -0
- package/dist/web/routes/library-management.js +120 -0
- package/dist/web/routes/library-management.js.map +1 -0
- package/dist/web/routes/library.d.ts +6 -0
- package/dist/web/routes/library.d.ts.map +1 -0
- package/dist/web/routes/library.js +28 -0
- package/dist/web/routes/library.js.map +1 -0
- package/dist/web/routes/oidc.d.ts +9 -0
- package/dist/web/routes/oidc.d.ts.map +1 -0
- package/dist/web/routes/oidc.js +203 -0
- package/dist/web/routes/oidc.js.map +1 -0
- package/dist/web/routes/requests.d.ts +6 -0
- package/dist/web/routes/requests.d.ts.map +1 -0
- package/dist/web/routes/requests.js +183 -0
- package/dist/web/routes/requests.js.map +1 -0
- package/dist/web/routes/sse.d.ts +6 -0
- package/dist/web/routes/sse.d.ts.map +1 -0
- package/dist/web/routes/sse.js +54 -0
- package/dist/web/routes/sse.js.map +1 -0
- package/package.json +33 -0
- package/package.json.license +2 -0
- package/src/auth/auth-service.ts +333 -0
- package/src/auth/http-server.ts +249 -0
- package/src/auth/jwt-secret.ts +65 -0
- package/src/auth/oidc/oidc-flow.ts +266 -0
- package/src/auth/oidc/oidc-provider-service.ts +174 -0
- package/src/auth/oidc/user-service.ts +227 -0
- package/src/auth/rate-limiter.ts +85 -0
- package/src/config/config-service.ts +434 -0
- package/src/config/validate.ts +153 -0
- package/src/content-types/registry.ts +194 -0
- package/src/datastore/db.ts +38 -0
- package/src/datastore/encrypt.ts +107 -0
- package/src/datastore/migrations.ts +367 -0
- package/src/datastore/storage.ts +83 -0
- package/src/events/event-bus.ts +180 -0
- package/src/events/spine.ts +87 -0
- package/src/events/trace.ts +38 -0
- package/src/host.ts +130 -0
- package/src/library/importer.ts +174 -0
- package/src/library/library-cache.ts +212 -0
- package/src/library/library-config.ts +22 -0
- package/src/library/library-scanner.ts +404 -0
- package/src/library/library-service.ts +282 -0
- package/src/library/metadata-matcher.ts +285 -0
- package/src/library/naming.ts +59 -0
- package/src/library/scan-controller.ts +490 -0
- package/src/library/source-resolver.ts +92 -0
- package/src/logging/logger.ts +115 -0
- package/src/main.ts +364 -0
- package/src/plugins/context.ts +138 -0
- package/src/plugins/discovery.ts +29 -0
- package/src/plugins/loader.ts +309 -0
- package/src/state/request-intake.ts +101 -0
- package/src/state/state-store.ts +621 -0
- package/src/state/trace-store.ts +27 -0
- package/src/types/plugin.ts +110 -0
- package/src/web/index.ts +182 -0
- package/src/web/routes/activity.ts +41 -0
- package/src/web/routes/auth.ts +170 -0
- package/src/web/routes/config.ts +282 -0
- package/src/web/routes/content-types.ts +42 -0
- package/src/web/routes/discovery.ts +156 -0
- package/src/web/routes/library-import.ts +196 -0
- package/src/web/routes/library-management.ts +140 -0
- package/src/web/routes/library.ts +46 -0
- package/src/web/routes/oidc.ts +260 -0
- package/src/web/routes/requests.ts +250 -0
- package/src/web/routes/sse.ts +92 -0
- package/tests/auth.test.ts +510 -0
- package/tests/config-route.test.ts +345 -0
- package/tests/config.test.ts +612 -0
- package/tests/content-types.test.ts +376 -0
- package/tests/datastore.test.ts +345 -0
- package/tests/e2e.test.ts +430 -0
- package/tests/event-bus.test.ts +925 -0
- package/tests/host.test.ts +449 -0
- package/tests/library-cache.test.ts +171 -0
- package/tests/library-scanner.test.ts +177 -0
- package/tests/library.test.ts +210 -0
- package/tests/loader-context.test.ts +644 -0
- package/tests/logger-jsonl.test.ts +154 -0
- package/tests/metadata-matcher.test.ts +198 -0
- package/tests/oidc.test.ts +218 -0
- package/tests/scan-controller.test.ts +328 -0
- package/tests/source-resolver.test.ts +100 -0
- package/tests/starr-video-integration.test.ts +153 -0
- package/tests/state-store.test.ts +700 -0
- package/tests/trace-history.test.ts +356 -0
- package/tsconfig.json +8 -0
- package/tsconfig.json.license +2 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
// SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { EventBusImpl } from "../src/events/event-bus.js";
|
|
6
|
+
import { createLogger } from "../src/logging/logger.js";
|
|
7
|
+
import {
|
|
8
|
+
isSpineType,
|
|
9
|
+
validateSpineEmission,
|
|
10
|
+
SPINE_EVENTS,
|
|
11
|
+
} from "../src/events/spine.js";
|
|
12
|
+
import type { EventBus, StarrEvent } from "../src/types/plugin.js";
|
|
13
|
+
|
|
14
|
+
function setup() {
|
|
15
|
+
const logger = createLogger("test", "error");
|
|
16
|
+
const impl = new EventBusImpl(logger);
|
|
17
|
+
const bus = impl.pluginBus("test-plugin");
|
|
18
|
+
return { impl, bus, logger };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// =========================================================================
|
|
22
|
+
// §3 — StarrEvent envelope
|
|
23
|
+
// =========================================================================
|
|
24
|
+
describe("StarrEvent envelope", () => {
|
|
25
|
+
it("host assigns id, traceId, source, timestamp, schemaVersion", () => {
|
|
26
|
+
const { bus } = setup();
|
|
27
|
+
let received: StarrEvent | null = null;
|
|
28
|
+
bus.on("test.event", (e) => {
|
|
29
|
+
received = e;
|
|
30
|
+
});
|
|
31
|
+
bus.emit("test.event", { hello: "world" });
|
|
32
|
+
|
|
33
|
+
expect(received).not.toBeNull();
|
|
34
|
+
expect(received!.type).toBe("test.event");
|
|
35
|
+
expect(received!.id).toBeTruthy();
|
|
36
|
+
expect(received!.traceId).toBeTruthy();
|
|
37
|
+
expect(received!.source).toBe("test-plugin");
|
|
38
|
+
expect(received!.timestamp).toBeTruthy();
|
|
39
|
+
expect(new Date(received!.timestamp).toISOString()).toBe(received!.timestamp);
|
|
40
|
+
expect(received!.schemaVersion).toBe(1);
|
|
41
|
+
expect(received!.payload).toEqual({ hello: "world" });
|
|
42
|
+
expect(received!.correlationId).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("events are immutable (Object.freeze)", () => {
|
|
46
|
+
const { bus } = setup();
|
|
47
|
+
let received: StarrEvent | null = null;
|
|
48
|
+
bus.on("test.event", (e) => {
|
|
49
|
+
received = e;
|
|
50
|
+
});
|
|
51
|
+
bus.emit("test.event", {});
|
|
52
|
+
|
|
53
|
+
expect(() => {
|
|
54
|
+
(received as any).type = "hacked";
|
|
55
|
+
}).toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("correlationId is passed through when specified in opts", () => {
|
|
59
|
+
const { bus } = setup();
|
|
60
|
+
let received: StarrEvent | null = null;
|
|
61
|
+
bus.on("test.event", (e) => {
|
|
62
|
+
received = e;
|
|
63
|
+
});
|
|
64
|
+
bus.emit("test.event", {}, { correlationId: "my-corr-id" });
|
|
65
|
+
|
|
66
|
+
expect(received!.correlationId).toBe("my-corr-id");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reply() sugar copies correlationId", () => {
|
|
70
|
+
const { bus } = setup();
|
|
71
|
+
const replies: StarrEvent[] = [];
|
|
72
|
+
bus.on("test.request", (e) => {
|
|
73
|
+
e.reply("test.reply", { answer: 42 });
|
|
74
|
+
});
|
|
75
|
+
bus.on("test.reply", (e) => {
|
|
76
|
+
replies.push(e);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
bus.emit("test.request", {}, { correlationId: "cid-1" });
|
|
80
|
+
|
|
81
|
+
expect(replies.length).toBe(1);
|
|
82
|
+
expect(replies[0].correlationId).toBe("cid-1");
|
|
83
|
+
expect(replies[0].payload).toEqual({ answer: 42 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// =========================================================================
|
|
88
|
+
// §4 — Delivery semantics
|
|
89
|
+
// =========================================================================
|
|
90
|
+
describe("delivery semantics", () => {
|
|
91
|
+
it("emit returns immediately; does not await handlers", () => {
|
|
92
|
+
const { bus } = setup();
|
|
93
|
+
let called = false;
|
|
94
|
+
bus.on("test.event", async () => {
|
|
95
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
96
|
+
called = true;
|
|
97
|
+
});
|
|
98
|
+
bus.emit("test.event", {});
|
|
99
|
+
expect(called).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("throwing handler is caught and isolated (does not crash emit)", () => {
|
|
103
|
+
const { bus } = setup();
|
|
104
|
+
const calls: string[] = [];
|
|
105
|
+
bus.on("test.event", () => {
|
|
106
|
+
calls.push("bad");
|
|
107
|
+
throw new Error("boom");
|
|
108
|
+
});
|
|
109
|
+
bus.on("test.event", () => {
|
|
110
|
+
calls.push("good");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(() => bus.emit("test.event", {})).not.toThrow();
|
|
114
|
+
expect(calls).toContain("bad");
|
|
115
|
+
expect(calls).toContain("good");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejecting async handler is caught and isolated", async () => {
|
|
119
|
+
const { bus } = setup();
|
|
120
|
+
const calls: string[] = [];
|
|
121
|
+
bus.on("test.event", async () => {
|
|
122
|
+
calls.push("bad");
|
|
123
|
+
throw new Error("async boom");
|
|
124
|
+
});
|
|
125
|
+
bus.on("test.event", () => {
|
|
126
|
+
calls.push("good");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
bus.emit("test.event", {});
|
|
130
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
131
|
+
expect(calls).toContain("bad");
|
|
132
|
+
expect(calls).toContain("good");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("event with no subscribers is silently dropped (no error)", () => {
|
|
136
|
+
const { bus } = setup();
|
|
137
|
+
expect(() => bus.emit("no.subscribers", {})).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// =========================================================================
|
|
142
|
+
// §5 — Subscription lifecycle
|
|
143
|
+
// =========================================================================
|
|
144
|
+
describe("subscription lifecycle", () => {
|
|
145
|
+
it("on() returns an unsubscribe handle", () => {
|
|
146
|
+
const { bus } = setup();
|
|
147
|
+
const unsub = bus.on("test.event", () => {});
|
|
148
|
+
expect(typeof unsub).toBe("function");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("unsubscribe removes the handler", () => {
|
|
152
|
+
const { bus } = setup();
|
|
153
|
+
const calls: string[] = [];
|
|
154
|
+
const unsub = bus.on("test.event", () => calls.push("first"));
|
|
155
|
+
bus.on("test.event", () => calls.push("second"));
|
|
156
|
+
|
|
157
|
+
unsub();
|
|
158
|
+
bus.emit("test.event", {});
|
|
159
|
+
|
|
160
|
+
expect(calls).not.toContain("first");
|
|
161
|
+
expect(calls).toContain("second");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("multiple handlers for same type all receive the event", () => {
|
|
165
|
+
const { bus } = setup();
|
|
166
|
+
const calls: string[] = [];
|
|
167
|
+
bus.on("test.event", () => calls.push("a"));
|
|
168
|
+
bus.on("test.event", () => calls.push("b"));
|
|
169
|
+
bus.on("test.event", () => calls.push("c"));
|
|
170
|
+
bus.emit("test.event", {});
|
|
171
|
+
expect(calls).toEqual(["a", "b", "c"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("handlers for different types are isolated", () => {
|
|
175
|
+
const { bus } = setup();
|
|
176
|
+
let calledA = false;
|
|
177
|
+
let calledB = false;
|
|
178
|
+
bus.on("type.a", () => {
|
|
179
|
+
calledA = true;
|
|
180
|
+
});
|
|
181
|
+
bus.on("type.b", () => {
|
|
182
|
+
calledB = true;
|
|
183
|
+
});
|
|
184
|
+
bus.emit("type.a", {});
|
|
185
|
+
expect(calledA).toBe(true);
|
|
186
|
+
expect(calledB).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// =========================================================================
|
|
191
|
+
// §5 PS2–PS3, §7 — Trace context propagation
|
|
192
|
+
// =========================================================================
|
|
193
|
+
describe("trace propagation", () => {
|
|
194
|
+
it("root emission mints a fresh traceId", () => {
|
|
195
|
+
const { bus } = setup();
|
|
196
|
+
const traceIds: string[] = [];
|
|
197
|
+
bus.on("test.event", (e) => traceIds.push(e.traceId));
|
|
198
|
+
bus.emit("test.event", {});
|
|
199
|
+
bus.emit("test.event", {});
|
|
200
|
+
expect(traceIds.length).toBe(2);
|
|
201
|
+
expect(traceIds[0]).toBeTruthy();
|
|
202
|
+
expect(traceIds[1]).toBeTruthy();
|
|
203
|
+
expect(traceIds[0]).not.toBe(traceIds[1]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("emit inside a handler inherits the parent traceId", () => {
|
|
207
|
+
const { bus } = setup();
|
|
208
|
+
let parentTrace: string | null = null;
|
|
209
|
+
let childTrace: string | null = null;
|
|
210
|
+
|
|
211
|
+
bus.on("parent.event", (e) => {
|
|
212
|
+
parentTrace = e.traceId;
|
|
213
|
+
bus.emit("child.event", {});
|
|
214
|
+
});
|
|
215
|
+
bus.on("child.event", (e) => {
|
|
216
|
+
childTrace = e.traceId;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
bus.emit("parent.event", {});
|
|
220
|
+
|
|
221
|
+
expect(parentTrace).toBeTruthy();
|
|
222
|
+
expect(childTrace).toBe(parentTrace);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("deeply nested emits all share the root traceId", () => {
|
|
226
|
+
const { bus } = setup();
|
|
227
|
+
const traces: string[] = [];
|
|
228
|
+
|
|
229
|
+
bus.on("level1", (e) => {
|
|
230
|
+
traces.push(`L1:${e.traceId.slice(0, 8)}`);
|
|
231
|
+
bus.emit("level2", {});
|
|
232
|
+
});
|
|
233
|
+
bus.on("level2", (e) => {
|
|
234
|
+
traces.push(`L2:${e.traceId.slice(0, 8)}`);
|
|
235
|
+
bus.emit("level3", {});
|
|
236
|
+
});
|
|
237
|
+
bus.on("level3", (e) => {
|
|
238
|
+
traces.push(`L3:${e.traceId.slice(0, 8)}`);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
bus.emit("level1", {});
|
|
242
|
+
|
|
243
|
+
expect(traces.length).toBe(3);
|
|
244
|
+
const ids = traces.map((t) => t.split(":")[1]);
|
|
245
|
+
expect(new Set(ids).size).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("concurrent handlers do not cross-attribute trace IDs", async () => {
|
|
249
|
+
const { bus } = setup();
|
|
250
|
+
const results: Array<{ handler: string; emittedTrace: string }> = [];
|
|
251
|
+
|
|
252
|
+
bus.on("trigger", async (e) => {
|
|
253
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
254
|
+
bus.emit("nested", { from: "A" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
bus.on("trigger", async (e) => {
|
|
258
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
259
|
+
bus.emit("nested", { from: "B" });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
bus.on("nested", (e) => {
|
|
263
|
+
results.push({
|
|
264
|
+
handler: e.payload.from as string,
|
|
265
|
+
emittedTrace: e.traceId,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
bus.emit("trigger", {});
|
|
270
|
+
bus.emit("trigger", {});
|
|
271
|
+
|
|
272
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
273
|
+
|
|
274
|
+
// 2 root emissions × 2 handlers = 4 nested events
|
|
275
|
+
expect(results.length).toBe(4);
|
|
276
|
+
|
|
277
|
+
// They should form 2 groups with distinct trace IDs (one per root emission)
|
|
278
|
+
const uniqueTraces = new Set(results.map((r) => r.emittedTrace));
|
|
279
|
+
expect(uniqueTraces.size).toBe(2);
|
|
280
|
+
|
|
281
|
+
// Group by trace — each group must contain both A and B (same chain)
|
|
282
|
+
const byTrace = new Map<string, string[]>();
|
|
283
|
+
for (const r of results) {
|
|
284
|
+
const list = byTrace.get(r.emittedTrace) ?? [];
|
|
285
|
+
list.push(r.handler);
|
|
286
|
+
byTrace.set(r.emittedTrace, list);
|
|
287
|
+
}
|
|
288
|
+
for (const handlers of byTrace.values()) {
|
|
289
|
+
expect(handlers).toContain("A");
|
|
290
|
+
expect(handlers).toContain("B");
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("emit from timer (outside handler) starts a new chain", async () => {
|
|
295
|
+
const { bus } = setup();
|
|
296
|
+
let rootTrace: string | null = null;
|
|
297
|
+
let timerTrace: string | null = null;
|
|
298
|
+
|
|
299
|
+
bus.on("root.event", (e) => {
|
|
300
|
+
rootTrace = e.traceId;
|
|
301
|
+
});
|
|
302
|
+
bus.on("timer.event", (e) => {
|
|
303
|
+
timerTrace = e.traceId;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
bus.emit("root.event", {});
|
|
307
|
+
|
|
308
|
+
await new Promise<void>((resolve) => {
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
bus.emit("timer.event", {});
|
|
311
|
+
resolve();
|
|
312
|
+
}, 10);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(rootTrace).toBeTruthy();
|
|
316
|
+
expect(timerTrace).toBeTruthy();
|
|
317
|
+
expect(timerTrace).not.toBe(rootTrace);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("traceId is present on every event (TC1)", () => {
|
|
321
|
+
const { bus } = setup();
|
|
322
|
+
const traces: string[] = [];
|
|
323
|
+
bus.on("a", (e) => traces.push(e.traceId));
|
|
324
|
+
bus.on("b", (e) => traces.push(e.traceId));
|
|
325
|
+
bus.emit("a", {});
|
|
326
|
+
bus.emit("b", {});
|
|
327
|
+
expect(traces.length).toBe(2);
|
|
328
|
+
traces.forEach((t) => expect(t).toBeTruthy());
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// =========================================================================
|
|
333
|
+
// §9 — Custom event namespacing
|
|
334
|
+
// =========================================================================
|
|
335
|
+
describe("custom event namespacing", () => {
|
|
336
|
+
it("plugins can emit custom events with <pluginId>:<name> format", () => {
|
|
337
|
+
const { impl } = setup();
|
|
338
|
+
const bus = impl.pluginBus("starr-tmdb");
|
|
339
|
+
let received: StarrEvent | null = null;
|
|
340
|
+
bus.on("starr-tmdb:cache.warmed", (e) => {
|
|
341
|
+
received = e;
|
|
342
|
+
});
|
|
343
|
+
bus.emit("starr-tmdb:cache.warmed", { count: 42 });
|
|
344
|
+
expect(received).not.toBeNull();
|
|
345
|
+
expect(received!.source).toBe("starr-tmdb");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("subscribing to another plugin's custom events works", () => {
|
|
349
|
+
const { impl } = setup();
|
|
350
|
+
const busA = impl.pluginBus("plugin-a");
|
|
351
|
+
const busB = impl.pluginBus("plugin-b");
|
|
352
|
+
|
|
353
|
+
let received: StarrEvent | null = null;
|
|
354
|
+
busB.on("plugin-a:something", (e) => {
|
|
355
|
+
received = e;
|
|
356
|
+
});
|
|
357
|
+
busA.emit("plugin-a:something", { x: 1 });
|
|
358
|
+
|
|
359
|
+
expect(received).not.toBeNull();
|
|
360
|
+
expect(received!.source).toBe("plugin-a");
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// =========================================================================
|
|
365
|
+
// §9 CE2–CE3 — Spine namespace enforcement
|
|
366
|
+
// =========================================================================
|
|
367
|
+
describe("spine namespace enforcement", () => {
|
|
368
|
+
it("isSpineType identifies reserved namespaces", () => {
|
|
369
|
+
expect(isSpineType("discovery.search")).toBe(true);
|
|
370
|
+
expect(isSpineType("content.requested")).toBe(true);
|
|
371
|
+
expect(isSpineType("release.found")).toBe(true);
|
|
372
|
+
expect(isSpineType("download.started")).toBe(true);
|
|
373
|
+
expect(isSpineType("item.updated")).toBe(true);
|
|
374
|
+
expect(isSpineType("import.started")).toBe(true);
|
|
375
|
+
expect(isSpineType("import.completed")).toBe(true);
|
|
376
|
+
expect(isSpineType("import.failed")).toBe(true);
|
|
377
|
+
expect(isSpineType("library.file_added")).toBe(true);
|
|
378
|
+
expect(isSpineType("starr-tmdb:cache.warmed")).toBe(false);
|
|
379
|
+
expect(isSpineType("custom.event")).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("validateSpineEmission returns message for unknown spine type", () => {
|
|
383
|
+
const msg = validateSpineEmission("some-plugin", "discovery.unknown");
|
|
384
|
+
expect(msg).toBeTruthy();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("validateSpineEmission returns null for non-spine custom events", () => {
|
|
388
|
+
const msg = validateSpineEmission("any-plugin", "starr-any:custom");
|
|
389
|
+
expect(msg).toBeNull();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("validateSpineEmission warns when non-core source emits core-only event", () => {
|
|
393
|
+
const msg = validateSpineEmission("some-plugin", "discovery.search");
|
|
394
|
+
expect(msg).toBeTruthy();
|
|
395
|
+
expect(msg).toContain("reserved for core");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("core can emit core-reserved events", () => {
|
|
399
|
+
const msg = validateSpineEmission("core", "discovery.search");
|
|
400
|
+
expect(msg).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("emit logs warning when spine violation is detected", () => {
|
|
404
|
+
const { bus } = setup();
|
|
405
|
+
bus.on("discovery.search", () => {});
|
|
406
|
+
expect(() => bus.emit("discovery.search", {})).not.toThrow();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("import events are recognized as spine events (core producer)", () => {
|
|
410
|
+
const msg = validateSpineEmission("core", "import.started");
|
|
411
|
+
expect(msg).toBeNull();
|
|
412
|
+
|
|
413
|
+
const msg2 = validateSpineEmission("core", "import.completed");
|
|
414
|
+
expect(msg2).toBeNull();
|
|
415
|
+
|
|
416
|
+
const msg3 = validateSpineEmission("core", "import.failed");
|
|
417
|
+
expect(msg3).toBeNull();
|
|
418
|
+
|
|
419
|
+
const msg4 = validateSpineEmission("core", "library.file_added");
|
|
420
|
+
expect(msg4).toBeNull();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("non-core source emitting import events is warned", () => {
|
|
424
|
+
const msg = validateSpineEmission("some-plugin", "import.started");
|
|
425
|
+
expect(msg).toBeTruthy();
|
|
426
|
+
expect(msg).toContain("reserved for core");
|
|
427
|
+
|
|
428
|
+
const msg2 = validateSpineEmission("some-plugin", "library.file_added");
|
|
429
|
+
expect(msg2).toBeTruthy();
|
|
430
|
+
expect(msg2).toContain("reserved for core");
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// =========================================================================
|
|
435
|
+
// §8 — Spine schemaVersion
|
|
436
|
+
// =========================================================================
|
|
437
|
+
describe("spine schemaVersion", () => {
|
|
438
|
+
it("spine events carry correct schemaVersion", () => {
|
|
439
|
+
const { impl } = setup();
|
|
440
|
+
const coreBus = impl.coreBus();
|
|
441
|
+
let received: StarrEvent | null = null;
|
|
442
|
+
coreBus.on("discovery.search", (e) => {
|
|
443
|
+
received = e;
|
|
444
|
+
});
|
|
445
|
+
coreBus.emit("discovery.search", { query: "test", contentTypeIds: [] });
|
|
446
|
+
expect(received!.schemaVersion).toBe(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("custom events get default schemaVersion 1", () => {
|
|
450
|
+
const { bus } = setup();
|
|
451
|
+
let received: StarrEvent | null = null;
|
|
452
|
+
bus.on("starr-tmdb:cache.warmed", (e) => {
|
|
453
|
+
received = e;
|
|
454
|
+
});
|
|
455
|
+
bus.emit("starr-tmdb:cache.warmed", {});
|
|
456
|
+
expect(received!.schemaVersion).toBe(1);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// =========================================================================
|
|
461
|
+
// §6.1 — request/response (caller-gathers)
|
|
462
|
+
// =========================================================================
|
|
463
|
+
describe("request/response (caller-gathers)", () => {
|
|
464
|
+
it("collects responses within windowMs", async () => {
|
|
465
|
+
const { impl } = setup();
|
|
466
|
+
const bus = impl.pluginBus("test-plugin");
|
|
467
|
+
|
|
468
|
+
impl.pluginBus("service").on("discovery.search", (e) => {
|
|
469
|
+
e.reply("discovery.results", { items: ["a", "b"] });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const replies = await bus.request("discovery.search", { query: "test" }, {
|
|
473
|
+
responseType: "discovery.results",
|
|
474
|
+
windowMs: 500,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(replies.length).toBe(1);
|
|
478
|
+
expect(replies[0].payload).toEqual({ items: ["a", "b"] });
|
|
479
|
+
expect(replies[0].correlationId).toBeTruthy();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("respects max replies", async () => {
|
|
483
|
+
const { impl } = setup();
|
|
484
|
+
const bus = impl.pluginBus("test-plugin");
|
|
485
|
+
|
|
486
|
+
impl.pluginBus("svc1").on("discovery.search", (e) => {
|
|
487
|
+
e.reply("discovery.results", { items: ["a"] });
|
|
488
|
+
});
|
|
489
|
+
impl.pluginBus("svc2").on("discovery.search", (e) => {
|
|
490
|
+
e.reply("discovery.results", { items: ["b"] });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const replies = await bus.request("discovery.search", {}, {
|
|
494
|
+
responseType: "discovery.results",
|
|
495
|
+
windowMs: 500,
|
|
496
|
+
max: 1,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(replies.length).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("resolves with empty array if no replies arrive", async () => {
|
|
503
|
+
const { bus } = setup();
|
|
504
|
+
|
|
505
|
+
const replies = await bus.request("no.responders", {}, {
|
|
506
|
+
responseType: "no.response",
|
|
507
|
+
windowMs: 50,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(replies).toEqual([]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("late replies after windowMs are dropped", async () => {
|
|
514
|
+
const { impl } = setup();
|
|
515
|
+
const bus = impl.pluginBus("test-plugin");
|
|
516
|
+
|
|
517
|
+
impl.pluginBus("slow-svc").on("discovery.search", async (e) => {
|
|
518
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
519
|
+
e.reply("discovery.results", { items: ["late"] });
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const replies = await bus.request("discovery.search", {}, {
|
|
523
|
+
responseType: "discovery.results",
|
|
524
|
+
windowMs: 50,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(replies).toEqual([]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("each request gets a unique correlationId", async () => {
|
|
531
|
+
const { impl } = setup();
|
|
532
|
+
const bus = impl.pluginBus("test-plugin");
|
|
533
|
+
const ids = new Set<string>();
|
|
534
|
+
|
|
535
|
+
impl.pluginBus("svc").on("test.req", (e) => {
|
|
536
|
+
ids.add(e.correlationId!);
|
|
537
|
+
e.reply("test.res", {});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await bus.request("test.req", {}, { responseType: "test.res", windowMs: 100 });
|
|
541
|
+
await bus.request("test.req", {}, { responseType: "test.res", windowMs: 100 });
|
|
542
|
+
|
|
543
|
+
expect(ids.size).toBe(2);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("request emissions start a new chain when called outside handler", async () => {
|
|
547
|
+
const { bus } = setup();
|
|
548
|
+
let requestTrace: string | null = null;
|
|
549
|
+
|
|
550
|
+
bus.on("test.req", (e) => {
|
|
551
|
+
requestTrace = e.traceId;
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
await bus.request("test.req", {}, { responseType: "test.res", windowMs: 50 });
|
|
555
|
+
|
|
556
|
+
expect(requestTrace).toBeTruthy();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("request inside a handler inherits parent traceId", async () => {
|
|
560
|
+
const { impl } = setup();
|
|
561
|
+
const bus = impl.pluginBus("test-plugin");
|
|
562
|
+
let parentTrace: string | null = null;
|
|
563
|
+
let requestTrace: string | null = null;
|
|
564
|
+
|
|
565
|
+
bus.on("parent", (e) => {
|
|
566
|
+
parentTrace = e.traceId;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
bus.on("test.req", (e) => {
|
|
570
|
+
requestTrace = e.traceId;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
impl.pluginBus("svc").on("test.req", (e) => {
|
|
574
|
+
e.reply("test.res", {});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
bus.on("parent", () => {
|
|
578
|
+
bus.request("test.req", {}, { responseType: "test.res", windowMs: 100 });
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
bus.emit("parent", {});
|
|
582
|
+
|
|
583
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
584
|
+
|
|
585
|
+
expect(parentTrace).toBeTruthy();
|
|
586
|
+
// The test.req handler is inside the parent handler so trace is inherited
|
|
587
|
+
expect(requestTrace).toBe(parentTrace);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// =========================================================================
|
|
592
|
+
// Integration: plugin workflow
|
|
593
|
+
// =========================================================================
|
|
594
|
+
describe("integration: plugin workflow", () => {
|
|
595
|
+
it("two plugin buses coordinate via events", async () => {
|
|
596
|
+
const { impl } = setup();
|
|
597
|
+
const tmdb = impl.pluginBus("starr-tmdb");
|
|
598
|
+
const indexer = impl.pluginBus("starr-torrent-indexer");
|
|
599
|
+
const ui = impl.coreBus();
|
|
600
|
+
|
|
601
|
+
const log: string[] = [];
|
|
602
|
+
|
|
603
|
+
tmdb.on("discovery.search", (e) => {
|
|
604
|
+
log.push(`tmdb: search for "${e.payload.query}"`);
|
|
605
|
+
e.reply("discovery.results", {
|
|
606
|
+
contentTypeId: "starr-video/movie@1",
|
|
607
|
+
items: [{ id: "tt123", title: "Dune" }],
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
indexer.on("content.requested", (e) => {
|
|
612
|
+
log.push(`indexer: find releases for ${e.payload.item.id}`);
|
|
613
|
+
e.reply("release.found", {
|
|
614
|
+
requestId: e.payload.requestId,
|
|
615
|
+
contentTypeId: e.payload.contentTypeId,
|
|
616
|
+
release: { title: "Dune 2021 1080p", quality: "1080p" },
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const results = await ui.request("discovery.search", {
|
|
621
|
+
query: "dune",
|
|
622
|
+
contentTypeIds: ["starr-video/movie@1"],
|
|
623
|
+
}, { responseType: "discovery.results", windowMs: 500 });
|
|
624
|
+
|
|
625
|
+
expect(results.length).toBe(1);
|
|
626
|
+
expect(log).toContain('tmdb: search for "dune"');
|
|
627
|
+
|
|
628
|
+
const releases = await ui.request("content.requested", {
|
|
629
|
+
requestId: "req-001",
|
|
630
|
+
contentTypeId: "starr-video/movie@1",
|
|
631
|
+
item: { id: "tt123", title: "Dune" },
|
|
632
|
+
}, { responseType: "release.found", windowMs: 500 });
|
|
633
|
+
|
|
634
|
+
expect(releases.length).toBe(1);
|
|
635
|
+
expect(log).toContain("indexer: find releases for tt123");
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// =========================================================================
|
|
640
|
+
// request() input validation
|
|
641
|
+
// =========================================================================
|
|
642
|
+
describe("request input validation", () => {
|
|
643
|
+
it("throws if responseType is empty", () => {
|
|
644
|
+
const { bus } = setup();
|
|
645
|
+
expect(() =>
|
|
646
|
+
bus.request("test.req", {}, { responseType: "", windowMs: 100 }),
|
|
647
|
+
).toThrow("responseType");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("throws if responseType is whitespace only", () => {
|
|
651
|
+
const { bus } = setup();
|
|
652
|
+
expect(() =>
|
|
653
|
+
bus.request("test.req", {}, { responseType: " ", windowMs: 100 }),
|
|
654
|
+
).toThrow("responseType");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("throws if windowMs is zero", () => {
|
|
658
|
+
const { bus } = setup();
|
|
659
|
+
expect(() =>
|
|
660
|
+
bus.request("test.req", {}, { responseType: "test.res", windowMs: 0 }),
|
|
661
|
+
).toThrow("windowMs");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("throws if windowMs is negative", () => {
|
|
665
|
+
const { bus } = setup();
|
|
666
|
+
expect(() =>
|
|
667
|
+
bus.request("test.req", {}, { responseType: "test.res", windowMs: -100 }),
|
|
668
|
+
).toThrow("windowMs");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("throws if windowMs is NaN", () => {
|
|
672
|
+
const { bus } = setup();
|
|
673
|
+
expect(() =>
|
|
674
|
+
bus.request("test.req", {}, { responseType: "test.res", windowMs: NaN }),
|
|
675
|
+
).toThrow("windowMs");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("throws if max is zero", () => {
|
|
679
|
+
const { bus } = setup();
|
|
680
|
+
expect(() =>
|
|
681
|
+
bus.request("test.req", {}, {
|
|
682
|
+
responseType: "test.res",
|
|
683
|
+
windowMs: 100,
|
|
684
|
+
max: 0,
|
|
685
|
+
}),
|
|
686
|
+
).toThrow("max");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("throws if max is negative", () => {
|
|
690
|
+
const { bus } = setup();
|
|
691
|
+
expect(() =>
|
|
692
|
+
bus.request("test.req", {}, {
|
|
693
|
+
responseType: "test.res",
|
|
694
|
+
windowMs: 100,
|
|
695
|
+
max: -1,
|
|
696
|
+
}),
|
|
697
|
+
).toThrow("max");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("throws if max is not an integer", () => {
|
|
701
|
+
const { bus } = setup();
|
|
702
|
+
expect(() =>
|
|
703
|
+
bus.request("test.req", {}, {
|
|
704
|
+
responseType: "test.res",
|
|
705
|
+
windowMs: 100,
|
|
706
|
+
max: 1.5,
|
|
707
|
+
}),
|
|
708
|
+
).toThrow("max");
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// =========================================================================
|
|
713
|
+
// §6.1 — Multiple concurrent requests within one trace
|
|
714
|
+
// =========================================================================
|
|
715
|
+
describe("concurrent requests", () => {
|
|
716
|
+
it("multiple concurrent requests within one trace have distinct correlationIds and don't cross-contaminate", async () => {
|
|
717
|
+
const { impl } = setup();
|
|
718
|
+
const bus = impl.pluginBus("test-plugin");
|
|
719
|
+
|
|
720
|
+
// Track which request each reply belongs to
|
|
721
|
+
const repliesA: unknown[] = [];
|
|
722
|
+
const repliesB: unknown[] = [];
|
|
723
|
+
const traceIds: string[] = [];
|
|
724
|
+
|
|
725
|
+
// Service that replies to both request types
|
|
726
|
+
impl.pluginBus("svc").on("req.a", (e) => {
|
|
727
|
+
e.reply("res.a", { from: "A" });
|
|
728
|
+
});
|
|
729
|
+
impl.pluginBus("svc").on("req.b", (e) => {
|
|
730
|
+
e.reply("res.b", { from: "B" });
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Capture trace IDs
|
|
734
|
+
bus.on("req.a", (e) => { traceIds.push(e.traceId!); });
|
|
735
|
+
bus.on("req.b", (e) => { traceIds.push(e.traceId!); });
|
|
736
|
+
|
|
737
|
+
// Fire two requests from within a single handler (same trace)
|
|
738
|
+
// Use max:1 so each promise resolves on first reply, not waiting for windowMs
|
|
739
|
+
bus.on("trigger", () => {
|
|
740
|
+
bus.request("req.a", {}, { responseType: "res.a", windowMs: 500, max: 1 })
|
|
741
|
+
.then((r) => { repliesA.push(...r.map(e => e.payload)); });
|
|
742
|
+
bus.request("req.b", {}, { responseType: "res.b", windowMs: 500, max: 1 })
|
|
743
|
+
.then((r) => { repliesB.push(...r.map(e => e.payload)); });
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
bus.emit("trigger", {});
|
|
747
|
+
|
|
748
|
+
// Wait for both requests to settle
|
|
749
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
750
|
+
|
|
751
|
+
// Each request got exactly its own reply type
|
|
752
|
+
expect(repliesA).toEqual([{ from: "A" }]);
|
|
753
|
+
expect(repliesB).toEqual([{ from: "B" }]);
|
|
754
|
+
|
|
755
|
+
// Both requests share the same traceId (same chain)
|
|
756
|
+
expect(traceIds.length).toBe(2);
|
|
757
|
+
expect(traceIds[0]).toBe(traceIds[1]);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// =========================================================================
|
|
762
|
+
// §7 TC1a — correlationId: only on request rounds, not plain emit
|
|
763
|
+
// =========================================================================
|
|
764
|
+
describe("correlationId (TC1a)", () => {
|
|
765
|
+
it("plain emit never sets correlationId (TC1a)", () => {
|
|
766
|
+
const { bus } = setup();
|
|
767
|
+
let received: StarrEvent | null = null;
|
|
768
|
+
bus.on("plain.event", (e) => { received = e; });
|
|
769
|
+
|
|
770
|
+
// Plain emit — no opts
|
|
771
|
+
bus.emit("plain.event", { x: 1 });
|
|
772
|
+
expect(received!.correlationId).toBeUndefined();
|
|
773
|
+
|
|
774
|
+
// Another plain emit to be sure
|
|
775
|
+
let received2: StarrEvent | null = null;
|
|
776
|
+
bus.on("plain.event2", (e) => { received2 = e; });
|
|
777
|
+
bus.emit("plain.event2", { x: 2 });
|
|
778
|
+
expect(received2!.correlationId).toBeUndefined();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("request round events carry correlationId", async () => {
|
|
782
|
+
const { impl } = setup();
|
|
783
|
+
const bus = impl.pluginBus("test-plugin");
|
|
784
|
+
|
|
785
|
+
let reqCorrelationId: string | undefined;
|
|
786
|
+
let resCorrelationId: string | undefined;
|
|
787
|
+
|
|
788
|
+
bus.on("test.req", (e) => { reqCorrelationId = e.correlationId; });
|
|
789
|
+
|
|
790
|
+
impl.pluginBus("svc").on("test.req", (e) => {
|
|
791
|
+
e.reply("test.res", { ok: true });
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Subscribe to response type to capture its correlationId
|
|
795
|
+
let resEvent: StarrEvent | null = null;
|
|
796
|
+
impl.pluginBus("svc").on("test.res", (e) => { resEvent = e; });
|
|
797
|
+
|
|
798
|
+
// Manually trigger what request() does to verify correlationId on both sides
|
|
799
|
+
// We use request() since it's the only way correlationId is minted
|
|
800
|
+
await bus.request("test.req", {}, { responseType: "test.res", windowMs: 200 });
|
|
801
|
+
|
|
802
|
+
// Both request and reply carry the same correlationId
|
|
803
|
+
expect(reqCorrelationId).toBeTruthy();
|
|
804
|
+
expect(resEvent?.correlationId).toBe(reqCorrelationId);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// =========================================================================
|
|
809
|
+
// §6.2 — Third-party gathering (subscription + traceId grouping)
|
|
810
|
+
// =========================================================================
|
|
811
|
+
describe("third-party gathering by traceId", () => {
|
|
812
|
+
it("events in the same chain share the same traceId", () => {
|
|
813
|
+
// Verify the fundamental property: all events emitted (directly or
|
|
814
|
+
// indirectly) from a single root emission carry the same traceId.
|
|
815
|
+
const { bus } = setup();
|
|
816
|
+
const traceIds: string[] = [];
|
|
817
|
+
|
|
818
|
+
bus.on("step1", (e) => {
|
|
819
|
+
traceIds.push(`step1:${e.traceId}`);
|
|
820
|
+
bus.emit("step2", {});
|
|
821
|
+
});
|
|
822
|
+
bus.on("step2", (e) => {
|
|
823
|
+
traceIds.push(`step2:${e.traceId}`);
|
|
824
|
+
bus.emit("step3", {});
|
|
825
|
+
});
|
|
826
|
+
bus.on("step3", (e) => {
|
|
827
|
+
traceIds.push(`step3:${e.traceId}`);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
bus.emit("step1", {}); // root emission → new trace
|
|
831
|
+
|
|
832
|
+
expect(traceIds.length).toBe(3);
|
|
833
|
+
// All three share the same trace UUID
|
|
834
|
+
const ids = traceIds.map((s) => s.split(":")[1]);
|
|
835
|
+
expect(new Set(ids).size).toBe(1);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("a subscriber can group events by traceId", () => {
|
|
839
|
+
// Simulate the selection plugin pattern: subscribe to release.found
|
|
840
|
+
// and group by traceId. The plugin would then apply its own window
|
|
841
|
+
// and quorum policy.
|
|
842
|
+
const { impl } = setup();
|
|
843
|
+
const indexerA = impl.pluginBus("starr-torrent-indexer-a");
|
|
844
|
+
const indexerB = impl.pluginBus("starr-torrent-indexer-b");
|
|
845
|
+
const selector = impl.pluginBus("starr-basic-selection");
|
|
846
|
+
|
|
847
|
+
const grouped = new Map<string, unknown[]>();
|
|
848
|
+
|
|
849
|
+
// The "selection plugin" subscribes to release.found and groups by traceId
|
|
850
|
+
selector.on("release.found", (e) => {
|
|
851
|
+
const list = grouped.get(e.traceId) ?? [];
|
|
852
|
+
list.push(e.payload);
|
|
853
|
+
grouped.set(e.traceId, list);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Simulate: core emits content.requested (starts a chain)
|
|
857
|
+
const coreBus = impl.coreBus();
|
|
858
|
+
let chainTraceId: string | null = null;
|
|
859
|
+
|
|
860
|
+
coreBus.on("content.requested", (e) => {
|
|
861
|
+
chainTraceId = e.traceId;
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Indexers listen for content.requested and emit release.found replies.
|
|
865
|
+
// They use emit() (not request()) and the traceId is inherited from the
|
|
866
|
+
// content.requested event's chain via AsyncLocalStorage.
|
|
867
|
+
coreBus.on("content.requested", (e) => {
|
|
868
|
+
// Indexer A responds
|
|
869
|
+
indexerA.emit("release.found", {
|
|
870
|
+
requestId: "req-1",
|
|
871
|
+
release: { title: "Dune 1080p", indexer: "A" },
|
|
872
|
+
});
|
|
873
|
+
// Indexer B responds
|
|
874
|
+
indexerB.emit("release.found", {
|
|
875
|
+
requestId: "req-1",
|
|
876
|
+
release: { title: "Dune 4K", indexer: "B" },
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Fire the request
|
|
881
|
+
coreBus.emit("content.requested", {
|
|
882
|
+
requestId: "req-1",
|
|
883
|
+
contentTypeId: "starr-video/movie@1",
|
|
884
|
+
item: { title: "Dune" },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Verify: all release.found events were grouped by the same traceId
|
|
888
|
+
expect(chainTraceId).toBeTruthy();
|
|
889
|
+
expect(grouped.size).toBe(1);
|
|
890
|
+
|
|
891
|
+
const releases = grouped.get(chainTraceId!)!;
|
|
892
|
+
expect(releases.length).toBe(2);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("separate chains produce separate traceId groups", () => {
|
|
896
|
+
const { impl } = setup();
|
|
897
|
+
const coreBus = impl.coreBus();
|
|
898
|
+
const selector = impl.pluginBus("starr-basic-selection");
|
|
899
|
+
|
|
900
|
+
const byTrace = new Map<string, number>();
|
|
901
|
+
|
|
902
|
+
selector.on("release.found", (e) => {
|
|
903
|
+
const count = byTrace.get(e.traceId) ?? 0;
|
|
904
|
+
byTrace.set(e.traceId, count + 1);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// Simulate: for each content.requested, an indexer emits one release.found
|
|
908
|
+
coreBus.on("content.requested", (e) => {
|
|
909
|
+
impl.pluginBus("indexer").emit("release.found", {
|
|
910
|
+
requestId: e.payload.requestId,
|
|
911
|
+
release: { title: "Test" },
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// Two separate user requests → two separate chains
|
|
916
|
+
coreBus.emit("content.requested", { requestId: "req-1", item: { title: "A" } });
|
|
917
|
+
coreBus.emit("content.requested", { requestId: "req-2", item: { title: "B" } });
|
|
918
|
+
|
|
919
|
+
// Two distinct traceIds, each with one release.found
|
|
920
|
+
expect(byTrace.size).toBe(2);
|
|
921
|
+
for (const count of byTrace.values()) {
|
|
922
|
+
expect(count).toBe(1);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
});
|