@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,449 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
5
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import Database from "better-sqlite3";
9
+ import { discoverPluginDirs } from "../src/plugins/discovery.js";
10
+ import { createLogger } from "../src/logging/logger.js";
11
+ import { createContext } from "../src/plugins/context.js";
12
+ import { ConfigService } from "../src/config/config-service.js";
13
+ import { loadPlugins, stopPlugins } from "../src/plugins/loader.js";
14
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
15
+ import { Starr } from "../src/host.js";
16
+ import type { RegisterFn } from "../src/types/plugin.js";
17
+
18
+ function createTestDb(): Database.Database {
19
+ const db = new Database(":memory:");
20
+ db.pragma("journal_mode = WAL");
21
+ db.pragma("foreign_keys = ON");
22
+ runMigrations(db, CORE_MIGRATIONS, createLogger("test", "silent"));
23
+ return db;
24
+ }
25
+
26
+ describe("discoverPluginDirs", () => {
27
+ let tmpDir: string;
28
+
29
+ beforeAll(async () => {
30
+ tmpDir = await mkdtemp(path.join(tmpdir(), "starr-discovery-"));
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await rm(tmpDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("returns empty array for non-existent directory", async () => {
38
+ const dirs = await discoverPluginDirs("/nonexistent/path");
39
+ expect(dirs).toEqual([]);
40
+ });
41
+
42
+ it("returns directories sorted lexically by name", async () => {
43
+ await mkdir(path.join(tmpDir, "zebra"), { recursive: true });
44
+ await writeFile(path.join(tmpDir, "zebra", "package.json"), "{}");
45
+ await mkdir(path.join(tmpDir, "alpha"), { recursive: true });
46
+ await writeFile(path.join(tmpDir, "alpha", "package.json"), "{}");
47
+ await mkdir(path.join(tmpDir, "beta"), { recursive: true });
48
+ await writeFile(path.join(tmpDir, "beta", "package.json"), "{}");
49
+
50
+ const dirs = await discoverPluginDirs(tmpDir);
51
+ const names = dirs.map((d) => path.basename(d));
52
+ expect(names).toEqual(["alpha", "beta", "zebra"]);
53
+ });
54
+
55
+ it("ignores hidden directories", async () => {
56
+ await mkdir(path.join(tmpDir, ".hidden"), { recursive: true });
57
+ await writeFile(path.join(tmpDir, ".hidden", "package.json"), "{}");
58
+
59
+ const dirs = await discoverPluginDirs(tmpDir);
60
+ const names = dirs.map((d) => path.basename(d));
61
+ expect(names).not.toContain(".hidden");
62
+ });
63
+
64
+ it("ignores node_modules directory", async () => {
65
+ await mkdir(path.join(tmpDir, "node_modules"), { recursive: true });
66
+ await writeFile(path.join(tmpDir, "node_modules", "package.json"), "{}");
67
+
68
+ const dirs = await discoverPluginDirs(tmpDir);
69
+ const names = dirs.map((d) => path.basename(d));
70
+ expect(names).not.toContain("node_modules");
71
+ });
72
+
73
+ it("ignores directories without package.json", async () => {
74
+ await mkdir(path.join(tmpDir, "not-a-plugin"), { recursive: true });
75
+
76
+ const dirs = await discoverPluginDirs(tmpDir);
77
+ const names = dirs.map((d) => path.basename(d));
78
+ expect(names).not.toContain("not-a-plugin");
79
+ });
80
+
81
+ it("ignores files (non-directories)", async () => {
82
+ await writeFile(path.join(tmpDir, "some-file.txt"), "hello");
83
+
84
+ const dirs = await discoverPluginDirs(tmpDir);
85
+ const names = dirs.map((d) => path.basename(d));
86
+ expect(names).not.toContain("some-file.txt");
87
+ });
88
+ });
89
+
90
+ describe("logger", () => {
91
+ it("creates logger with default tag core", () => {
92
+ const logger = createLogger("core", "info");
93
+ expect(logger).toBeDefined();
94
+ expect(typeof logger.info).toBe("function");
95
+ expect(typeof logger.debug).toBe("function");
96
+ expect(typeof logger.warn).toBe("function");
97
+ expect(typeof logger.error).toBe("function");
98
+ expect(typeof logger.child).toBe("function");
99
+ });
100
+
101
+ it("child logger appends tag", () => {
102
+ const logger = createLogger("core", "debug");
103
+ const child = logger.child("test");
104
+ expect(child).toBeDefined();
105
+ // Just ensure no throw
106
+ child.debug("test message");
107
+ });
108
+
109
+ it("respects log level (debug not shown at info level)", () => {
110
+ // Just smoke test — debug logger at info level shouldn't output
111
+ const logger = createLogger("core", "info");
112
+ logger.debug("should not appear"); // won't crash
113
+ });
114
+ });
115
+
116
+ describe("Starr host", () => {
117
+ it("constructs with options and exposes service slots", () => {
118
+ const logger = createLogger("core", "info");
119
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
120
+
121
+ expect(starr.options.pluginsDir).toBe("/tmp");
122
+ expect(starr.apiVersion).toBe("1.0.0");
123
+ expect(starr.isRunning).toBe(false);
124
+ expect(starr.eventBus).toBeDefined();
125
+ expect(typeof starr.eventBus.on).toBe("function");
126
+ expect(typeof starr.eventBus.emit).toBe("function");
127
+ });
128
+
129
+ it("markRunning and markStopped change state", () => {
130
+ const logger = createLogger("core", "info");
131
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
132
+
133
+ expect(starr.isRunning).toBe(false);
134
+ starr.markRunning();
135
+ expect(starr.isRunning).toBe(true);
136
+ starr.markStopped();
137
+ expect(starr.isRunning).toBe(false);
138
+ });
139
+
140
+ it("setRuntimes and getRuntimes work", () => {
141
+ const logger = createLogger("core", "info");
142
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
143
+ const runtimes = [
144
+ { meta: { id: "a", version: "1.0.0", starrApiVersion: "^1.0.0" }, lifecycle: {}, startOrder: 0 },
145
+ ];
146
+ starr.setRuntimes(runtimes);
147
+ expect(starr.getRuntimes()).toEqual(runtimes);
148
+ });
149
+ });
150
+
151
+ describe("plugin context", () => {
152
+ let db: Database.Database;
153
+
154
+ beforeAll(() => {
155
+ db = createTestDb();
156
+ });
157
+
158
+ function createTestConfigService(): ConfigService {
159
+ return new ConfigService(db, createLogger("test", "silent"));
160
+ }
161
+
162
+ it("throws if ctx.config is called before ctx.define", () => {
163
+ const logger = createLogger("core", "info");
164
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
165
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
166
+
167
+ expect(() => ctx.config({})).toThrow("ctx.define must be called before any other context method");
168
+ });
169
+
170
+ it("throws if ctx.events.on is called before ctx.define", () => {
171
+ const logger = createLogger("core", "info");
172
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
173
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
174
+
175
+ expect(() => ctx.events.on("test", () => {})).toThrow("ctx.define must be called before any other context method");
176
+ });
177
+
178
+ it("throws on duplicate ctx.define", () => {
179
+ const logger = createLogger("core", "info");
180
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
181
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
182
+
183
+ ctx.define({ id: "test", version: "1.0.0", starrApiVersion: "^1.0.0" });
184
+ expect(() => ctx.define({ id: "test2", version: "1.0.0", starrApiVersion: "^1.0.0" })).toThrow(
185
+ "ctx.define can only be called once",
186
+ );
187
+ });
188
+
189
+ it("throws on empty plugin id", () => {
190
+ const logger = createLogger("core", "info");
191
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
192
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
193
+
194
+ expect(() => ctx.define({ id: "", version: "1.0.0", starrApiVersion: "^1.0.0" })).toThrow("plugin id is required");
195
+ });
196
+
197
+ it("throws on invalid version", () => {
198
+ const logger = createLogger("core", "info");
199
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
200
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
201
+
202
+ expect(() => ctx.define({ id: "test", version: "not-semver", starrApiVersion: "^1.0.0" })).toThrow(
203
+ "invalid plugin version",
204
+ );
205
+ });
206
+
207
+ it("throws on API version mismatch", () => {
208
+ const logger = createLogger("core", "info");
209
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
210
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
211
+
212
+ expect(() => ctx.define({ id: "test", version: "1.0.0", starrApiVersion: "^2.0.0" })).toThrow(
213
+ "API version mismatch",
214
+ );
215
+ });
216
+
217
+ it("successful define and config (returns from ConfigService)", () => {
218
+ const logger = createLogger("core", "info");
219
+ const starr = new Starr({ pluginsDir: "/tmp", apiVersion: "1.0.0", logger });
220
+ const ctx = createContext(starr, "/tmp/test-plugin", db, createTestConfigService());
221
+
222
+ ctx.define({ id: "test", version: "1.0.0", starrApiVersion: "^1.0.0" });
223
+ const cfg = ctx.config<{ foo?: string }>({
224
+ foo: { type: "string", label: "Foo", default: "bar" },
225
+ });
226
+ expect(cfg.foo).toBe("bar");
227
+ });
228
+ });
229
+
230
+ describe("plugin loader lifecycle", () => {
231
+ let tmpDir: string;
232
+ let db: Database.Database;
233
+
234
+ beforeAll(async () => {
235
+ tmpDir = await mkdtemp(path.join(tmpdir(), "starr-loader-"));
236
+ db = createTestDb();
237
+ });
238
+
239
+ afterAll(async () => {
240
+ await rm(tmpDir, { recursive: true, force: true });
241
+ db.close();
242
+ });
243
+
244
+ async function writePlugin(dirName: string, code: string, pkgOverrides: Record<string, unknown> = {}) {
245
+ const dir = path.join(tmpDir, dirName);
246
+ await mkdir(dir, { recursive: true });
247
+ await writeFile(
248
+ path.join(dir, "package.json"),
249
+ JSON.stringify({ type: "module", main: "./index.js", ...pkgOverrides }),
250
+ );
251
+ await writeFile(path.join(dir, "index.js"), code);
252
+ return dir;
253
+ }
254
+
255
+ it("registers all plugins before starting any", async () => {
256
+ const events: string[] = [];
257
+ const key = "__starr_reg_order";
258
+ (globalThis as Record<string, unknown>)[key] = events;
259
+
260
+ await writePlugin(
261
+ "plugin-a",
262
+ `
263
+ const k = "${key}";
264
+ export default function register(ctx) {
265
+ globalThis[k].push("register-a");
266
+ ctx.define({ id: "a", version: "1.0.0", starrApiVersion: "^1.0.0" });
267
+ return {
268
+ start() { globalThis[k].push("start-a"); },
269
+ stop() { globalThis[k].push("stop-a"); },
270
+ };
271
+ }
272
+ `,
273
+ );
274
+ await writePlugin(
275
+ "plugin-b",
276
+ `
277
+ const k = "${key}";
278
+ export default function register(ctx) {
279
+ globalThis[k].push("register-b");
280
+ ctx.define({ id: "b", version: "1.0.0", starrApiVersion: "^1.0.0" });
281
+ return {
282
+ start() { globalThis[k].push("start-b"); },
283
+ stop() { globalThis[k].push("stop-b"); },
284
+ };
285
+ }
286
+ `,
287
+ );
288
+
289
+ const logger = createLogger("core", "error");
290
+ const starr = new Starr({ pluginsDir: tmpDir, apiVersion: "1.0.0", logger });
291
+ starr.datastore = { db, dbPath: ":memory:", close: () => {} };
292
+ starr.configService = new ConfigService(db, createLogger("test", "silent"));
293
+
294
+ await loadPlugins(starr);
295
+
296
+ // All registers should happen before any start
297
+ const regA = events.indexOf("register-a");
298
+ const regB = events.indexOf("register-b");
299
+ const startA = events.indexOf("start-a");
300
+ const startB = events.indexOf("start-b");
301
+
302
+ expect(regA).toBeLessThan(startA);
303
+ expect(regA).toBeLessThan(startB);
304
+ expect(regB).toBeLessThan(startA);
305
+ expect(regB).toBeLessThan(startB);
306
+
307
+ // Start order should be discovery order (lexical)
308
+ expect(startA).toBeLessThan(startB);
309
+ });
310
+
311
+ it("stops plugins in reverse start order", async () => {
312
+ const events: string[] = [];
313
+ const key = "__starr_stop_order";
314
+ (globalThis as Record<string, unknown>)[key] = events;
315
+
316
+ await writePlugin(
317
+ "plugin-c",
318
+ `
319
+ const k = "${key}";
320
+ export default function register(ctx) {
321
+ globalThis[k].push("register-c");
322
+ ctx.define({ id: "c", version: "1.0.0", starrApiVersion: "^1.0.0" });
323
+ return {
324
+ start() { globalThis[k].push("start-c"); },
325
+ stop() { globalThis[k].push("stop-c"); },
326
+ };
327
+ }
328
+ `,
329
+ );
330
+ await writePlugin(
331
+ "plugin-d",
332
+ `
333
+ const k = "${key}";
334
+ export default function register(ctx) {
335
+ globalThis[k].push("register-d");
336
+ ctx.define({ id: "d", version: "1.0.0", starrApiVersion: "^1.0.0" });
337
+ return {
338
+ start() { globalThis[k].push("start-d"); },
339
+ stop() { globalThis[k].push("stop-d"); },
340
+ };
341
+ }
342
+ `,
343
+ );
344
+
345
+ const logger = createLogger("core", "error");
346
+ const starr = new Starr({ pluginsDir: tmpDir, apiVersion: "1.0.0", logger });
347
+ starr.datastore = { db, dbPath: ":memory:", close: () => {} };
348
+ starr.configService = new ConfigService(db, createLogger("test", "silent"));
349
+
350
+ await loadPlugins(starr);
351
+ await stopPlugins(starr);
352
+
353
+ const stopC = events.indexOf("stop-c");
354
+ const stopD = events.indexOf("stop-d");
355
+
356
+ // Reverse order: stop-d (started second) before stop-c (started first)
357
+ expect(stopD).toBeLessThan(stopC);
358
+ });
359
+
360
+ async function writePluginIn(baseDir: string, dirName: string, code: string) {
361
+ const dir = path.join(baseDir, dirName);
362
+ await mkdir(dir, { recursive: true });
363
+ await writeFile(path.join(dir, "package.json"), JSON.stringify({ type: "module", main: "./index.js" }));
364
+ await writeFile(path.join(dir, "index.js"), code);
365
+ return dir;
366
+ }
367
+
368
+ it("plugin that throws during register does not prevent other plugins", async () => {
369
+ const tmpDir2 = await mkdtemp(path.join(tmpdir(), "starr-isolate-"));
370
+ const events: string[] = [];
371
+ const key = "__starr_isolate";
372
+ (globalThis as Record<string, unknown>)[key] = events;
373
+ try {
374
+ await writePluginIn(tmpDir2, "bad-plugin", `
375
+ export default function register(ctx) {
376
+ throw new Error("boom");
377
+ }
378
+ `);
379
+
380
+ await writePluginIn(tmpDir2, "good-plugin", `
381
+ const k = "${key}";
382
+ export default function register(ctx) {
383
+ globalThis[k].push("good-registered");
384
+ ctx.define({ id: "good", version: "1.0.0", starrApiVersion: "^1.0.0" });
385
+ return {
386
+ start() { globalThis[k].push("good-started"); },
387
+ };
388
+ }
389
+ `);
390
+
391
+ const logger = createLogger("core", "error");
392
+ const starr = new Starr({ pluginsDir: tmpDir2, apiVersion: "1.0.0", logger });
393
+ starr.datastore = { db, dbPath: ":memory:", close: () => {} };
394
+ starr.configService = new ConfigService(db, createLogger("test", "silent"));
395
+
396
+ await loadPlugins(starr);
397
+
398
+ expect(events).toContain("good-registered");
399
+ expect(events).toContain("good-started");
400
+
401
+ const runtimes = starr.getRuntimes();
402
+ const ids = runtimes.map((r) => r.meta.id);
403
+ expect(ids).toContain("good");
404
+ expect(ids).not.toContain("bad-plugin");
405
+ } finally {
406
+ await rm(tmpDir2, { recursive: true, force: true });
407
+ }
408
+ });
409
+
410
+ it("API version mismatch rejects plugin but continues loading others", async () => {
411
+ const tmpDir2 = await mkdtemp(path.join(tmpdir(), "starr-apiver-"));
412
+ const events: string[] = [];
413
+ const key = "__starr_apiver";
414
+ (globalThis as Record<string, unknown>)[key] = events;
415
+ try {
416
+ await writePluginIn(tmpDir2, "mismatch-plugin", `
417
+ export default function register(ctx) {
418
+ ctx.define({ id: "mismatch", version: "1.0.0", starrApiVersion: "^2.0.0" });
419
+ }
420
+ `);
421
+
422
+ await writePluginIn(tmpDir2, "ok-plugin", `
423
+ const k = "${key}";
424
+ export default function register(ctx) {
425
+ globalThis[k].push("ok-registered");
426
+ ctx.define({ id: "ok", version: "1.0.0", starrApiVersion: "^1.0.0" });
427
+ return { start() { globalThis[k].push("ok-started"); } };
428
+ }
429
+ `);
430
+
431
+ const logger = createLogger("core", "error");
432
+ const starr = new Starr({ pluginsDir: tmpDir2, apiVersion: "1.0.0", logger });
433
+ starr.datastore = { db, dbPath: ":memory:", close: () => {} };
434
+ starr.configService = new ConfigService(db, createLogger("test", "silent"));
435
+
436
+ await loadPlugins(starr);
437
+
438
+ expect(events).toContain("ok-registered");
439
+ expect(events).toContain("ok-started");
440
+
441
+ const runtimes = starr.getRuntimes();
442
+ const ids = runtimes.map((r) => r.meta.id);
443
+ expect(ids).toContain("ok");
444
+ expect(ids).not.toContain("mismatch");
445
+ } finally {
446
+ await rm(tmpDir2, { recursive: true, force: true });
447
+ }
448
+ });
449
+ });
@@ -0,0 +1,171 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
5
+ import Database from "better-sqlite3";
6
+ import { EventBusImpl } from "../src/events/event-bus.js";
7
+ import { createLogger } from "../src/logging/logger.js";
8
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
9
+ import { LibraryCache } from "../src/library/library-cache.js";
10
+
11
+ function setup() {
12
+ const db = new Database(":memory:");
13
+ db.pragma("journal_mode = WAL");
14
+ db.pragma("foreign_keys = ON");
15
+ const logger = createLogger("test", "silent");
16
+ runMigrations(db, CORE_MIGRATIONS, logger);
17
+
18
+ const impl = new EventBusImpl(logger);
19
+ const coreBus = impl.coreBus();
20
+
21
+ // Insert a tracked request with status 'imported'
22
+ db.prepare(`INSERT INTO tracked_requests (request_id, content_type_id, content_identity, item, owner_user_id, status, library_path)
23
+ VALUES (?,?,?,?,?,?,?)`).run(
24
+ "req-imported-1",
25
+ "starr-video/movie@1",
26
+ "starr-video/movie@1::tmdb=123",
27
+ JSON.stringify({
28
+ title: "Dune",
29
+ contentTypeId: "starr-video/movie@1",
30
+ externalIds: { tmdb: "123" },
31
+ images: [{ role: "poster", url: "https://example.com/poster.jpg" }],
32
+ }),
33
+ "admin",
34
+ "imported",
35
+ "/media/Dune/Dune.mkv",
36
+ );
37
+
38
+ // Insert a library_paths row for it
39
+ db.prepare(`INSERT INTO library_paths (request_id, library_path, library_root, imported_at)
40
+ VALUES (?,?,?,?)`).run(
41
+ "req-imported-1",
42
+ "/media/Dune/Dune.mkv",
43
+ "/media",
44
+ "2026-01-15T10:00:00.000Z",
45
+ );
46
+
47
+ // Insert a tracked request with other status (should NOT be in cache)
48
+ db.prepare(`INSERT INTO tracked_requests (request_id, content_type_id, content_identity, item, owner_user_id, status)
49
+ VALUES (?,?,?,?,?,?)`).run(
50
+ "req-downloading-1",
51
+ "starr-video/movie@1",
52
+ "starr-video/movie@1::tmdb=456",
53
+ JSON.stringify({ title: "Still Downloading", contentTypeId: "starr-video/movie@1", externalIds: { tmdb: "456" } }),
54
+ "admin",
55
+ "downloading",
56
+ );
57
+
58
+ const cache = new LibraryCache(db, coreBus, logger);
59
+
60
+ return { db, coreBus, cache };
61
+ }
62
+
63
+ describe("LibraryCache", () => {
64
+ it("refreshFromDb populates the cache from imported requests", () => {
65
+ const { cache } = setup();
66
+
67
+ cache.refreshFromDb();
68
+
69
+ const items = cache.getItems();
70
+ expect(items.length).toBe(1);
71
+ expect(items[0].title).toBe("Dune");
72
+ expect(items[0].libraryPath).toBe("/media/Dune/Dune.mkv");
73
+ expect(items[0].contentTypeId).toBe("starr-video/movie@1");
74
+ });
75
+
76
+ it("getItems filters by content type", () => {
77
+ const { cache } = setup();
78
+
79
+ cache.refreshFromDb();
80
+
81
+ const movieItems = cache.getItems("starr-video/movie@1");
82
+ expect(movieItems.length).toBe(1);
83
+
84
+ const unknownItems = cache.getItems("nonexistent/type@1");
85
+ expect(unknownItems.length).toBe(0);
86
+ });
87
+
88
+ it("count returns the number of cached items", () => {
89
+ const { cache } = setup();
90
+
91
+ cache.refreshFromDb();
92
+
93
+ expect(cache.count()).toBe(1);
94
+ });
95
+
96
+ it("caches images as parsed objects", () => {
97
+ const { cache } = setup();
98
+
99
+ cache.refreshFromDb();
100
+
101
+ const items = cache.getItems();
102
+ expect(items[0].images).toHaveLength(1);
103
+ expect(items[0].images[0].role).toBe("poster");
104
+ expect(items[0].images[0].url).toBe("https://example.com/poster.jpg");
105
+ });
106
+
107
+ it("is initially empty before refreshFromDb", () => {
108
+ const { cache } = setup();
109
+
110
+ // No refreshFromDb called
111
+ expect(cache.count()).toBe(0);
112
+ expect(cache.getItems()).toHaveLength(0);
113
+ });
114
+
115
+ it("handles library.file_added event by inserting into cache", () => {
116
+ const { db, coreBus, cache } = setup();
117
+
118
+ cache.refreshFromDb();
119
+ expect(cache.count()).toBe(1);
120
+
121
+ // Add another imported request behind the scenes
122
+ db.prepare(`INSERT INTO tracked_requests (request_id, content_type_id, content_identity, item, owner_user_id, status, library_path)
123
+ VALUES (?,?,?,?,?,?,?)`).run(
124
+ "req-imported-2",
125
+ "starr-video/movie@1",
126
+ "starr-video/movie@1::tmdb=789",
127
+ JSON.stringify({
128
+ title: "Another Movie",
129
+ contentTypeId: "starr-video/movie@1",
130
+ externalIds: { tmdb: "789" },
131
+ }),
132
+ "admin",
133
+ "imported",
134
+ "/media/Another/Another.mkv",
135
+ );
136
+ db.prepare(`INSERT INTO library_paths (request_id, library_path, library_root, imported_at)
137
+ VALUES (?,?,?,?)`).run(
138
+ "req-imported-2",
139
+ "/media/Another/Another.mkv",
140
+ "/media",
141
+ "2026-02-20T12:00:00.000Z",
142
+ );
143
+
144
+ // Emit the event that triggers cache update
145
+ coreBus.emit("library.file_added", {
146
+ requestId: "req-imported-2",
147
+ libraryPath: "/media/Another/Another.mkv",
148
+ contentTypeId: "starr-video/movie@1",
149
+ contentIdentity: "starr-video/movie@1::tmdb=789",
150
+ });
151
+
152
+ expect(cache.count()).toBe(2);
153
+ const titles = cache.getItems().map((i) => i.title);
154
+ expect(titles).toContain("Another Movie");
155
+ expect(titles).toContain("Dune");
156
+ });
157
+
158
+ it("handles request.deleted event by removing from cache", () => {
159
+ const { coreBus, cache } = setup();
160
+
161
+ cache.refreshFromDb();
162
+ expect(cache.count()).toBe(1);
163
+
164
+ coreBus.emit("request.deleted", {
165
+ requestId: "req-imported-1",
166
+ previousStatus: "imported",
167
+ });
168
+
169
+ expect(cache.count()).toBe(0);
170
+ });
171
+ });