@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.
Files changed (290) hide show
  1. package/README.rst +75 -0
  2. package/data/.nfs.811bbb0d.43c34.4 +0 -0
  3. package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
  4. package/data/.nfs.82d127f7.fffff5704.4 +0 -0
  5. package/data/.nfs.82d127fe.fffff5704.4 +0 -0
  6. package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
  7. package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
  8. package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
  9. package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
  10. package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
  11. package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
  12. package/data/.nfs.82d18536.fffff5e24.4 +0 -0
  13. package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
  14. package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
  15. package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
  16. package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
  17. package/data/.nfs.8314122e.fffffb044.4 +0 -0
  18. package/data/.nfs.83141236.fffffb044.4 +0 -0
  19. package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
  20. package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
  21. package/data/logs/starr.log +66244 -0
  22. package/data/starr.db +0 -0
  23. package/data/starr.db-shm +0 -0
  24. package/data/starr.db-wal +0 -0
  25. package/data/starr.jwt +1 -0
  26. package/data/starr.key +1 -0
  27. package/dist/auth/auth-service.d.ts +42 -0
  28. package/dist/auth/auth-service.d.ts.map +1 -0
  29. package/dist/auth/auth-service.js +226 -0
  30. package/dist/auth/auth-service.js.map +1 -0
  31. package/dist/auth/http-server.d.ts +16 -0
  32. package/dist/auth/http-server.d.ts.map +1 -0
  33. package/dist/auth/http-server.js +207 -0
  34. package/dist/auth/http-server.js.map +1 -0
  35. package/dist/auth/jwt-secret.d.ts +6 -0
  36. package/dist/auth/jwt-secret.d.ts.map +1 -0
  37. package/dist/auth/jwt-secret.js +51 -0
  38. package/dist/auth/jwt-secret.js.map +1 -0
  39. package/dist/auth/oidc/oidc-flow.d.ts +60 -0
  40. package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
  41. package/dist/auth/oidc/oidc-flow.js +201 -0
  42. package/dist/auth/oidc/oidc-flow.js.map +1 -0
  43. package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
  44. package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
  45. package/dist/auth/oidc/oidc-provider-service.js +124 -0
  46. package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
  47. package/dist/auth/oidc/user-service.d.ts +67 -0
  48. package/dist/auth/oidc/user-service.d.ts.map +1 -0
  49. package/dist/auth/oidc/user-service.js +177 -0
  50. package/dist/auth/oidc/user-service.js.map +1 -0
  51. package/dist/auth/rate-limiter.d.ts +31 -0
  52. package/dist/auth/rate-limiter.d.ts.map +1 -0
  53. package/dist/auth/rate-limiter.js +70 -0
  54. package/dist/auth/rate-limiter.js.map +1 -0
  55. package/dist/config/config-service.d.ts +83 -0
  56. package/dist/config/config-service.d.ts.map +1 -0
  57. package/dist/config/config-service.js +309 -0
  58. package/dist/config/config-service.js.map +1 -0
  59. package/dist/config/validate.d.ts +12 -0
  60. package/dist/config/validate.d.ts.map +1 -0
  61. package/dist/config/validate.js +140 -0
  62. package/dist/config/validate.js.map +1 -0
  63. package/dist/content-types/registry.d.ts +75 -0
  64. package/dist/content-types/registry.d.ts.map +1 -0
  65. package/dist/content-types/registry.js +171 -0
  66. package/dist/content-types/registry.js.map +1 -0
  67. package/dist/datastore/db.d.ts +9 -0
  68. package/dist/datastore/db.d.ts.map +1 -0
  69. package/dist/datastore/db.js +27 -0
  70. package/dist/datastore/db.js.map +1 -0
  71. package/dist/datastore/encrypt.d.ts +9 -0
  72. package/dist/datastore/encrypt.d.ts.map +1 -0
  73. package/dist/datastore/encrypt.js +84 -0
  74. package/dist/datastore/encrypt.js.map +1 -0
  75. package/dist/datastore/migrations.d.ts +10 -0
  76. package/dist/datastore/migrations.d.ts.map +1 -0
  77. package/dist/datastore/migrations.js +340 -0
  78. package/dist/datastore/migrations.js.map +1 -0
  79. package/dist/datastore/storage.d.ts +4 -0
  80. package/dist/datastore/storage.d.ts.map +1 -0
  81. package/dist/datastore/storage.js +60 -0
  82. package/dist/datastore/storage.js.map +1 -0
  83. package/dist/events/event-bus.d.ts +15 -0
  84. package/dist/events/event-bus.d.ts.map +1 -0
  85. package/dist/events/event-bus.js +140 -0
  86. package/dist/events/event-bus.js.map +1 -0
  87. package/dist/events/spine.d.ts +31 -0
  88. package/dist/events/spine.d.ts.map +1 -0
  89. package/dist/events/spine.js +68 -0
  90. package/dist/events/spine.js.map +1 -0
  91. package/dist/events/trace.d.ts +24 -0
  92. package/dist/events/trace.d.ts.map +1 -0
  93. package/dist/events/trace.js +33 -0
  94. package/dist/events/trace.js.map +1 -0
  95. package/dist/host.d.ts +65 -0
  96. package/dist/host.d.ts.map +1 -0
  97. package/dist/host.js +94 -0
  98. package/dist/host.js.map +1 -0
  99. package/dist/library/importer.d.ts +29 -0
  100. package/dist/library/importer.d.ts.map +1 -0
  101. package/dist/library/importer.js +145 -0
  102. package/dist/library/importer.js.map +1 -0
  103. package/dist/library/library-cache.d.ts +46 -0
  104. package/dist/library/library-cache.d.ts.map +1 -0
  105. package/dist/library/library-cache.js +152 -0
  106. package/dist/library/library-cache.js.map +1 -0
  107. package/dist/library/library-config.d.ts +12 -0
  108. package/dist/library/library-config.d.ts.map +1 -0
  109. package/dist/library/library-config.js +10 -0
  110. package/dist/library/library-config.js.map +1 -0
  111. package/dist/library/library-scanner.d.ts +62 -0
  112. package/dist/library/library-scanner.d.ts.map +1 -0
  113. package/dist/library/library-scanner.js +336 -0
  114. package/dist/library/library-scanner.js.map +1 -0
  115. package/dist/library/library-service.d.ts +65 -0
  116. package/dist/library/library-service.d.ts.map +1 -0
  117. package/dist/library/library-service.js +197 -0
  118. package/dist/library/library-service.js.map +1 -0
  119. package/dist/library/metadata-matcher.d.ts +56 -0
  120. package/dist/library/metadata-matcher.d.ts.map +1 -0
  121. package/dist/library/metadata-matcher.js +207 -0
  122. package/dist/library/metadata-matcher.js.map +1 -0
  123. package/dist/library/naming.d.ts +8 -0
  124. package/dist/library/naming.d.ts.map +1 -0
  125. package/dist/library/naming.js +47 -0
  126. package/dist/library/naming.js.map +1 -0
  127. package/dist/library/scan-controller.d.ts +95 -0
  128. package/dist/library/scan-controller.d.ts.map +1 -0
  129. package/dist/library/scan-controller.js +379 -0
  130. package/dist/library/scan-controller.js.map +1 -0
  131. package/dist/library/source-resolver.d.ts +17 -0
  132. package/dist/library/source-resolver.d.ts.map +1 -0
  133. package/dist/library/source-resolver.js +75 -0
  134. package/dist/library/source-resolver.js.map +1 -0
  135. package/dist/logging/logger.d.ts +3 -0
  136. package/dist/logging/logger.d.ts.map +1 -0
  137. package/dist/logging/logger.js +107 -0
  138. package/dist/logging/logger.js.map +1 -0
  139. package/dist/main.d.ts +36 -0
  140. package/dist/main.d.ts.map +1 -0
  141. package/dist/main.js +229 -0
  142. package/dist/main.js.map +1 -0
  143. package/dist/plugins/context.d.ts +10 -0
  144. package/dist/plugins/context.d.ts.map +1 -0
  145. package/dist/plugins/context.js +110 -0
  146. package/dist/plugins/context.js.map +1 -0
  147. package/dist/plugins/discovery.d.ts +2 -0
  148. package/dist/plugins/discovery.d.ts.map +1 -0
  149. package/dist/plugins/discovery.js +32 -0
  150. package/dist/plugins/discovery.js.map +1 -0
  151. package/dist/plugins/loader.d.ts +9 -0
  152. package/dist/plugins/loader.d.ts.map +1 -0
  153. package/dist/plugins/loader.js +245 -0
  154. package/dist/plugins/loader.js.map +1 -0
  155. package/dist/state/request-intake.d.ts +26 -0
  156. package/dist/state/request-intake.d.ts.map +1 -0
  157. package/dist/state/request-intake.js +74 -0
  158. package/dist/state/request-intake.js.map +1 -0
  159. package/dist/state/state-store.d.ts +146 -0
  160. package/dist/state/state-store.d.ts.map +1 -0
  161. package/dist/state/state-store.js +465 -0
  162. package/dist/state/state-store.js.map +1 -0
  163. package/dist/state/trace-store.d.ts +12 -0
  164. package/dist/state/trace-store.d.ts.map +1 -0
  165. package/dist/state/trace-store.js +24 -0
  166. package/dist/state/trace-store.js.map +1 -0
  167. package/dist/types/plugin.d.ts +93 -0
  168. package/dist/types/plugin.d.ts.map +1 -0
  169. package/dist/types/plugin.js +4 -0
  170. package/dist/types/plugin.js.map +1 -0
  171. package/dist/web/index.d.ts +10 -0
  172. package/dist/web/index.d.ts.map +1 -0
  173. package/dist/web/index.js +153 -0
  174. package/dist/web/index.js.map +1 -0
  175. package/dist/web/routes/activity.d.ts +6 -0
  176. package/dist/web/routes/activity.d.ts.map +1 -0
  177. package/dist/web/routes/activity.js +31 -0
  178. package/dist/web/routes/activity.js.map +1 -0
  179. package/dist/web/routes/auth.d.ts +6 -0
  180. package/dist/web/routes/auth.d.ts.map +1 -0
  181. package/dist/web/routes/auth.js +142 -0
  182. package/dist/web/routes/auth.js.map +1 -0
  183. package/dist/web/routes/config.d.ts +7 -0
  184. package/dist/web/routes/config.d.ts.map +1 -0
  185. package/dist/web/routes/config.js +204 -0
  186. package/dist/web/routes/config.js.map +1 -0
  187. package/dist/web/routes/content-types.d.ts +6 -0
  188. package/dist/web/routes/content-types.d.ts.map +1 -0
  189. package/dist/web/routes/content-types.js +29 -0
  190. package/dist/web/routes/content-types.js.map +1 -0
  191. package/dist/web/routes/discovery.d.ts +6 -0
  192. package/dist/web/routes/discovery.d.ts.map +1 -0
  193. package/dist/web/routes/discovery.js +111 -0
  194. package/dist/web/routes/discovery.js.map +1 -0
  195. package/dist/web/routes/library-import.d.ts +6 -0
  196. package/dist/web/routes/library-import.d.ts.map +1 -0
  197. package/dist/web/routes/library-import.js +120 -0
  198. package/dist/web/routes/library-import.js.map +1 -0
  199. package/dist/web/routes/library-management.d.ts +6 -0
  200. package/dist/web/routes/library-management.d.ts.map +1 -0
  201. package/dist/web/routes/library-management.js +120 -0
  202. package/dist/web/routes/library-management.js.map +1 -0
  203. package/dist/web/routes/library.d.ts +6 -0
  204. package/dist/web/routes/library.d.ts.map +1 -0
  205. package/dist/web/routes/library.js +28 -0
  206. package/dist/web/routes/library.js.map +1 -0
  207. package/dist/web/routes/oidc.d.ts +9 -0
  208. package/dist/web/routes/oidc.d.ts.map +1 -0
  209. package/dist/web/routes/oidc.js +203 -0
  210. package/dist/web/routes/oidc.js.map +1 -0
  211. package/dist/web/routes/requests.d.ts +6 -0
  212. package/dist/web/routes/requests.d.ts.map +1 -0
  213. package/dist/web/routes/requests.js +183 -0
  214. package/dist/web/routes/requests.js.map +1 -0
  215. package/dist/web/routes/sse.d.ts +6 -0
  216. package/dist/web/routes/sse.d.ts.map +1 -0
  217. package/dist/web/routes/sse.js +54 -0
  218. package/dist/web/routes/sse.js.map +1 -0
  219. package/package.json +33 -0
  220. package/package.json.license +2 -0
  221. package/src/auth/auth-service.ts +333 -0
  222. package/src/auth/http-server.ts +249 -0
  223. package/src/auth/jwt-secret.ts +65 -0
  224. package/src/auth/oidc/oidc-flow.ts +266 -0
  225. package/src/auth/oidc/oidc-provider-service.ts +174 -0
  226. package/src/auth/oidc/user-service.ts +227 -0
  227. package/src/auth/rate-limiter.ts +85 -0
  228. package/src/config/config-service.ts +434 -0
  229. package/src/config/validate.ts +153 -0
  230. package/src/content-types/registry.ts +194 -0
  231. package/src/datastore/db.ts +38 -0
  232. package/src/datastore/encrypt.ts +107 -0
  233. package/src/datastore/migrations.ts +367 -0
  234. package/src/datastore/storage.ts +83 -0
  235. package/src/events/event-bus.ts +180 -0
  236. package/src/events/spine.ts +87 -0
  237. package/src/events/trace.ts +38 -0
  238. package/src/host.ts +130 -0
  239. package/src/library/importer.ts +174 -0
  240. package/src/library/library-cache.ts +212 -0
  241. package/src/library/library-config.ts +22 -0
  242. package/src/library/library-scanner.ts +404 -0
  243. package/src/library/library-service.ts +282 -0
  244. package/src/library/metadata-matcher.ts +285 -0
  245. package/src/library/naming.ts +59 -0
  246. package/src/library/scan-controller.ts +490 -0
  247. package/src/library/source-resolver.ts +92 -0
  248. package/src/logging/logger.ts +115 -0
  249. package/src/main.ts +364 -0
  250. package/src/plugins/context.ts +138 -0
  251. package/src/plugins/discovery.ts +29 -0
  252. package/src/plugins/loader.ts +309 -0
  253. package/src/state/request-intake.ts +101 -0
  254. package/src/state/state-store.ts +621 -0
  255. package/src/state/trace-store.ts +27 -0
  256. package/src/types/plugin.ts +110 -0
  257. package/src/web/index.ts +182 -0
  258. package/src/web/routes/activity.ts +41 -0
  259. package/src/web/routes/auth.ts +170 -0
  260. package/src/web/routes/config.ts +282 -0
  261. package/src/web/routes/content-types.ts +42 -0
  262. package/src/web/routes/discovery.ts +156 -0
  263. package/src/web/routes/library-import.ts +196 -0
  264. package/src/web/routes/library-management.ts +140 -0
  265. package/src/web/routes/library.ts +46 -0
  266. package/src/web/routes/oidc.ts +260 -0
  267. package/src/web/routes/requests.ts +250 -0
  268. package/src/web/routes/sse.ts +92 -0
  269. package/tests/auth.test.ts +510 -0
  270. package/tests/config-route.test.ts +345 -0
  271. package/tests/config.test.ts +612 -0
  272. package/tests/content-types.test.ts +376 -0
  273. package/tests/datastore.test.ts +345 -0
  274. package/tests/e2e.test.ts +430 -0
  275. package/tests/event-bus.test.ts +925 -0
  276. package/tests/host.test.ts +449 -0
  277. package/tests/library-cache.test.ts +171 -0
  278. package/tests/library-scanner.test.ts +177 -0
  279. package/tests/library.test.ts +210 -0
  280. package/tests/loader-context.test.ts +644 -0
  281. package/tests/logger-jsonl.test.ts +154 -0
  282. package/tests/metadata-matcher.test.ts +198 -0
  283. package/tests/oidc.test.ts +218 -0
  284. package/tests/scan-controller.test.ts +328 -0
  285. package/tests/source-resolver.test.ts +100 -0
  286. package/tests/starr-video-integration.test.ts +153 -0
  287. package/tests/state-store.test.ts +700 -0
  288. package/tests/trace-history.test.ts +356 -0
  289. package/tsconfig.json +8 -0
  290. 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
+ });