@storyshelf/core 0.1.2 → 0.3.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 (275) hide show
  1. package/README.md +50 -46
  2. package/dist/adapter/auth.d.mts +4 -4
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/{adapters → adapter}/capture-queue.d.mts +6 -4
  5. package/dist/adapter/capture-queue.mjs +4 -0
  6. package/dist/adapter/capture-runner.d.mts +7 -7
  7. package/dist/adapter/database.d.mts +20 -9
  8. package/dist/adapter/database.mjs +60 -1
  9. package/dist/adapter/database.mjs.map +1 -0
  10. package/dist/adapter/git-host/comments.d.mts +41 -0
  11. package/dist/adapter/git-host/comments.mjs +54 -0
  12. package/dist/adapter/git-host/comments.mjs.map +1 -0
  13. package/dist/adapter/git-host/helpers.d.mts +1 -0
  14. package/dist/adapter/git-host/helpers.mjs +1 -0
  15. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  16. package/dist/adapter/git-host.d.mts +3 -7
  17. package/dist/adapter/init.d.mts +58 -0
  18. package/dist/adapter/init.mjs +93 -0
  19. package/dist/adapter/init.mjs.map +1 -0
  20. package/dist/adapter/metadata.d.mts +83 -0
  21. package/dist/adapter/metadata.mjs +4 -0
  22. package/dist/adapter/storage.d.mts +15 -4
  23. package/dist/adapters/webhook-events.mjs +20 -4
  24. package/dist/adapters/webhook-events.mjs.map +1 -1
  25. package/dist/capture/adapter.d.mts +7 -1
  26. package/dist/capture/adapter.mjs +3 -0
  27. package/dist/capture/adapter.mjs.map +1 -1
  28. package/dist/capture/dispatch.mjs +150 -0
  29. package/dist/capture/dispatch.mjs.map +1 -0
  30. package/dist/capture/orchestrator.d.mts +9 -0
  31. package/dist/capture/orchestrator.mjs +11 -44
  32. package/dist/capture/orchestrator.mjs.map +1 -1
  33. package/dist/capture/pipeline.d.mts +4 -1
  34. package/dist/capture/pipeline.mjs +6 -6
  35. package/dist/capture/pipeline.mjs.map +1 -1
  36. package/dist/capture/queue.d.mts +4 -1
  37. package/dist/capture/queue.mjs +3 -1
  38. package/dist/capture/queue.mjs.map +1 -1
  39. package/dist/capture/skip-checks.mjs +76 -0
  40. package/dist/capture/skip-checks.mjs.map +1 -0
  41. package/dist/capture/statics.mjs +124 -0
  42. package/dist/capture/statics.mjs.map +1 -0
  43. package/dist/capture/status-fanout.mjs +1 -0
  44. package/dist/capture/status-fanout.mjs.map +1 -1
  45. package/dist/capture/storybook.d.mts +1 -0
  46. package/dist/capture/storybook.mjs +1 -0
  47. package/dist/capture/storybook.mjs.map +1 -1
  48. package/dist/capture.d.mts +8 -0
  49. package/dist/capture.mjs +9 -0
  50. package/dist/config.d.mts +37 -22
  51. package/dist/config.mjs +28 -2
  52. package/dist/config.mjs.map +1 -1
  53. package/dist/ddl.d.mts +2 -1
  54. package/dist/ddl.mjs +2 -1
  55. package/dist/ddl.mjs.map +1 -1
  56. package/dist/diff/engine.d.mts +8 -0
  57. package/dist/diff/engine.mjs +8 -0
  58. package/dist/diff/engine.mjs.map +1 -1
  59. package/dist/diff/options.d.mts +3 -1
  60. package/dist/diff.d.mts +5 -0
  61. package/dist/diff.mjs +6 -0
  62. package/dist/index.d.mts +34 -24
  63. package/dist/index.mjs +211 -252
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/logger.d.mts +3 -3
  66. package/dist/logger.mjs.map +1 -1
  67. package/dist/middleware/auth-gate.mjs +36 -0
  68. package/dist/middleware/auth-gate.mjs.map +1 -0
  69. package/dist/middleware/csrf.mjs +1 -0
  70. package/dist/middleware/csrf.mjs.map +1 -1
  71. package/dist/middleware/rate-limit.mjs +1 -0
  72. package/dist/middleware/rate-limit.mjs.map +1 -1
  73. package/dist/middleware/request-log.mjs +28 -0
  74. package/dist/middleware/request-log.mjs.map +1 -0
  75. package/dist/middleware/store-scope.mjs +29 -0
  76. package/dist/middleware/store-scope.mjs.map +1 -0
  77. package/dist/models/baseline.mjs +9 -5
  78. package/dist/models/baseline.mjs.map +1 -1
  79. package/dist/models/build.mjs +5 -1
  80. package/dist/models/build.mjs.map +1 -1
  81. package/dist/models/comment.mjs +4 -1
  82. package/dist/models/comment.mjs.map +1 -1
  83. package/dist/models/label.mjs +3 -1
  84. package/dist/models/label.mjs.map +1 -1
  85. package/dist/models/member.mjs +2 -1
  86. package/dist/models/member.mjs.map +1 -1
  87. package/dist/models/project.mjs +2 -1
  88. package/dist/models/project.mjs.map +1 -1
  89. package/dist/models/snapshot.mjs +2 -1
  90. package/dist/models/snapshot.mjs.map +1 -1
  91. package/dist/models/status-config.mjs +2 -1
  92. package/dist/models/status-config.mjs.map +1 -1
  93. package/dist/models/token.mjs +2 -1
  94. package/dist/models/token.mjs.map +1 -1
  95. package/dist/models/webhook.mjs +12 -3
  96. package/dist/models/webhook.mjs.map +1 -1
  97. package/dist/pages/build-detail.mjs +10 -4
  98. package/dist/pages/build-detail.mjs.map +1 -1
  99. package/dist/pages/build-diff-comments.mjs +113 -0
  100. package/dist/pages/build-diff-comments.mjs.map +1 -0
  101. package/dist/pages/build-diff-header.mjs +163 -0
  102. package/dist/pages/build-diff-header.mjs.map +1 -0
  103. package/dist/pages/build-diff-nav.mjs +66 -0
  104. package/dist/pages/build-diff-nav.mjs.map +1 -0
  105. package/dist/pages/build-diff-viewer.mjs +197 -0
  106. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  107. package/dist/pages/build-diff.mjs +66 -413
  108. package/dist/pages/build-diff.mjs.map +1 -1
  109. package/dist/pages/compute-jobs.mjs +3 -1
  110. package/dist/pages/compute-jobs.mjs.map +1 -1
  111. package/dist/pages/label-detail.mjs +4 -3
  112. package/dist/pages/label-detail.mjs.map +1 -1
  113. package/dist/pages/login.mjs +1 -0
  114. package/dist/pages/login.mjs.map +1 -1
  115. package/dist/pages/project-builds.mjs +6 -3
  116. package/dist/pages/project-builds.mjs.map +1 -1
  117. package/dist/pages/project-create.mjs +1 -0
  118. package/dist/pages/project-create.mjs.map +1 -1
  119. package/dist/pages/project-settings.mjs +1 -0
  120. package/dist/pages/project-settings.mjs.map +1 -1
  121. package/dist/pages/projects.mjs +12 -6
  122. package/dist/pages/projects.mjs.map +1 -1
  123. package/dist/pages/root.mjs +7 -3
  124. package/dist/pages/root.mjs.map +1 -1
  125. package/dist/pages/settings-general.mjs +1 -0
  126. package/dist/pages/settings-general.mjs.map +1 -1
  127. package/dist/pages/settings-labels.mjs +2 -1
  128. package/dist/pages/settings-labels.mjs.map +1 -1
  129. package/dist/pages/settings-members.mjs +2 -1
  130. package/dist/pages/settings-members.mjs.map +1 -1
  131. package/dist/pages/settings-status.mjs +3 -1
  132. package/dist/pages/settings-status.mjs.map +1 -1
  133. package/dist/pages/settings-tests.mjs +4 -3
  134. package/dist/pages/settings-tests.mjs.map +1 -1
  135. package/dist/pages/settings-tokens.mjs +1 -0
  136. package/dist/pages/settings-tokens.mjs.map +1 -1
  137. package/dist/pages/settings-webhooks.mjs +1 -0
  138. package/dist/pages/settings-webhooks.mjs.map +1 -1
  139. package/dist/pages/storybook.mjs +38 -1
  140. package/dist/pages/storybook.mjs.map +1 -1
  141. package/dist/{utils/paths.d.mts → paths.d.mts} +5 -0
  142. package/dist/{utils/paths.mjs → paths.mjs} +5 -0
  143. package/dist/paths.mjs.map +1 -0
  144. package/dist/retention/purge.mjs +2 -1
  145. package/dist/retention/purge.mjs.map +1 -1
  146. package/dist/routers/admin.mjs +2 -1
  147. package/dist/routers/admin.mjs.map +1 -1
  148. package/dist/routers/assets.mjs +1 -0
  149. package/dist/routers/assets.mjs.map +1 -1
  150. package/dist/routers/auth.mjs +1 -0
  151. package/dist/routers/auth.mjs.map +1 -1
  152. package/dist/routers/builds.handlers.mjs +114 -19
  153. package/dist/routers/builds.handlers.mjs.map +1 -1
  154. package/dist/routers/builds.mjs +55 -34
  155. package/dist/routers/builds.mjs.map +1 -1
  156. package/dist/routers/comments.mjs +1 -0
  157. package/dist/routers/comments.mjs.map +1 -1
  158. package/dist/routers/health.mjs +137 -0
  159. package/dist/routers/health.mjs.map +1 -0
  160. package/dist/routers/helpers.mjs +8 -2
  161. package/dist/routers/helpers.mjs.map +1 -1
  162. package/dist/routers/htmx.mjs +2 -0
  163. package/dist/routers/htmx.mjs.map +1 -1
  164. package/dist/routers/labels.mjs +2 -1
  165. package/dist/routers/labels.mjs.map +1 -1
  166. package/dist/routers/media.mjs +3 -2
  167. package/dist/routers/media.mjs.map +1 -1
  168. package/dist/routers/members.mjs +1 -0
  169. package/dist/routers/members.mjs.map +1 -1
  170. package/dist/routers/projects.mjs +2 -1
  171. package/dist/routers/projects.mjs.map +1 -1
  172. package/dist/routers/schemas.mjs +25 -0
  173. package/dist/routers/schemas.mjs.map +1 -1
  174. package/dist/routers/settings-general.mjs +95 -0
  175. package/dist/routers/settings-general.mjs.map +1 -0
  176. package/dist/routers/settings-labels.mjs +49 -0
  177. package/dist/routers/settings-labels.mjs.map +1 -0
  178. package/dist/routers/settings-members.mjs +30 -0
  179. package/dist/routers/settings-members.mjs.map +1 -0
  180. package/dist/routers/settings-status.mjs +73 -0
  181. package/dist/routers/settings-status.mjs.map +1 -0
  182. package/dist/routers/settings-tokens.mjs +40 -0
  183. package/dist/routers/settings-tokens.mjs.map +1 -0
  184. package/dist/routers/settings-webhooks.mjs +63 -0
  185. package/dist/routers/settings-webhooks.mjs.map +1 -0
  186. package/dist/routers/settings.handlers.mjs +93 -0
  187. package/dist/routers/settings.handlers.mjs.map +1 -0
  188. package/dist/routers/settings.mjs +14 -246
  189. package/dist/routers/settings.mjs.map +1 -1
  190. package/dist/routers/snapshots.mjs +2 -1
  191. package/dist/routers/snapshots.mjs.map +1 -1
  192. package/dist/routers/status-configs.mjs +2 -1
  193. package/dist/routers/status-configs.mjs.map +1 -1
  194. package/dist/routers/storybook.mjs +12 -4
  195. package/dist/routers/storybook.mjs.map +1 -1
  196. package/dist/routers/tokens.mjs +2 -1
  197. package/dist/routers/tokens.mjs.map +1 -1
  198. package/dist/routers/ui.mjs +5 -4
  199. package/dist/routers/ui.mjs.map +1 -1
  200. package/dist/routers/webhooks.mjs +7 -6
  201. package/dist/routers/webhooks.mjs.map +1 -1
  202. package/dist/schema/baseline.d.mts +18 -0
  203. package/dist/schema/baseline.mjs +22 -0
  204. package/dist/schema/baseline.mjs.map +1 -0
  205. package/dist/schema/build.d.mts +26 -0
  206. package/dist/schema/build.mjs +29 -0
  207. package/dist/schema/build.mjs.map +1 -0
  208. package/dist/schema/comment.d.mts +19 -0
  209. package/dist/schema/comment.mjs +26 -0
  210. package/dist/schema/comment.mjs.map +1 -0
  211. package/dist/schema/label.d.mts +25 -0
  212. package/dist/schema/label.mjs +30 -0
  213. package/dist/schema/label.mjs.map +1 -0
  214. package/dist/schema/member.d.mts +15 -0
  215. package/dist/schema/member.mjs +19 -0
  216. package/dist/schema/member.mjs.map +1 -0
  217. package/dist/schema/project.d.mts +34 -0
  218. package/dist/schema/project.mjs +25 -0
  219. package/dist/schema/project.mjs.map +1 -0
  220. package/dist/schema/snapshot.d.mts +30 -0
  221. package/dist/schema/snapshot.mjs +34 -0
  222. package/dist/schema/snapshot.mjs.map +1 -0
  223. package/dist/schema/status-config.d.mts +16 -0
  224. package/dist/schema/status-config.mjs +20 -0
  225. package/dist/schema/status-config.mjs.map +1 -0
  226. package/dist/schema/token.d.mts +15 -0
  227. package/dist/schema/token.mjs +19 -0
  228. package/dist/schema/token.mjs.map +1 -0
  229. package/dist/schema/user.d.mts +17 -0
  230. package/dist/schema/user.mjs +19 -0
  231. package/dist/schema/user.mjs.map +1 -0
  232. package/dist/schema/webhook.d.mts +16 -0
  233. package/dist/schema/webhook.mjs +20 -0
  234. package/dist/schema/webhook.mjs.map +1 -0
  235. package/dist/schema.d.mts +52 -4433
  236. package/dist/schema.mjs +37 -139
  237. package/dist/schema.mjs.map +1 -1
  238. package/dist/store.mjs +2 -0
  239. package/dist/store.mjs.map +1 -1
  240. package/dist/types.d.mts +13 -2
  241. package/dist/types.mjs +8 -0
  242. package/dist/types.mjs.map +1 -1
  243. package/dist/ui/document.mjs +1 -0
  244. package/dist/ui/document.mjs.map +1 -1
  245. package/dist/ui/feedback.mjs +24 -0
  246. package/dist/ui/feedback.mjs.map +1 -0
  247. package/dist/ui/{components.mjs → forms.mjs} +6 -16
  248. package/dist/ui/forms.mjs.map +1 -0
  249. package/dist/ui/styles.mjs +1 -0
  250. package/dist/ui/styles.mjs.map +1 -1
  251. package/dist/ui/theme.mjs +2 -0
  252. package/dist/ui/theme.mjs.map +1 -1
  253. package/dist/urls.d.mts +8 -0
  254. package/dist/urls.mjs +7 -0
  255. package/dist/urls.mjs.map +1 -1
  256. package/dist/utils/hash.mjs +3 -0
  257. package/dist/utils/hash.mjs.map +1 -1
  258. package/dist/utils/ulid.mjs +2 -0
  259. package/dist/utils/ulid.mjs.map +1 -1
  260. package/package.json +47 -45
  261. package/dist/adapters/metadata.d.mts +0 -24
  262. package/dist/models/baseline.d.mts +0 -20
  263. package/dist/models/build.d.mts +0 -50
  264. package/dist/models/comment.d.mts +0 -20
  265. package/dist/models/label.d.mts +0 -49
  266. package/dist/models/member.d.mts +0 -27
  267. package/dist/models/project.d.mts +0 -42
  268. package/dist/models/snapshot.d.mts +0 -46
  269. package/dist/models/token.d.mts +0 -33
  270. package/dist/models/webhook.d.mts +0 -37
  271. package/dist/retention/purge.d.mts +0 -28
  272. package/dist/ui/components.mjs.map +0 -1
  273. package/dist/ui/document.d.mts +0 -8
  274. package/dist/utils/paths.mjs.map +0 -1
  275. package/dist/utils/ulid.d.mts +0 -8
@@ -0,0 +1,93 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/adapters/init.ts
5
+ /** Thrown when an `app.lifecycle.init()` or `close()` run has failures. */
6
+ var AdapterLifecycleError = class extends Error {
7
+ phase;
8
+ failures;
9
+ constructor(phase, failures) {
10
+ super(`Adapter ${phase} failed: ${failures.map((f) => `${f.category}/${f.kind}`).join(", ")}`);
11
+ this.name = "AdapterLifecycleError";
12
+ this.phase = phase;
13
+ this.failures = failures;
14
+ }
15
+ };
16
+ function hookEntry(adapter, hook) {
17
+ const fn = adapter.lifecycle?.[hook];
18
+ if (!fn) return null;
19
+ const { category, kind, name } = adapter.metadata;
20
+ return {
21
+ category,
22
+ kind,
23
+ name,
24
+ run: async (ctx) => {
25
+ await fn(ctx);
26
+ }
27
+ };
28
+ }
29
+ function pushHook(entries, adapter, hook) {
30
+ if (!adapter) return;
31
+ const entry = hookEntry(adapter, hook);
32
+ if (entry) entries.push(entry);
33
+ }
34
+ function pushAll(entries, sources, hook) {
35
+ pushHook(entries, sources.database, hook);
36
+ pushHook(entries, sources.storage, hook);
37
+ pushHook(entries, sources.captureRunner, hook);
38
+ pushHook(entries, sources.captureQueue, hook);
39
+ pushHook(entries, sources.auth, hook);
40
+ for (const provider of sources.gitHosts ?? []) pushHook(entries, provider, hook);
41
+ }
42
+ /** Collect every available `init` hook in deterministic order. */
43
+ function collectInits(sources) {
44
+ const entries = [];
45
+ pushAll(entries, sources, "init");
46
+ return entries;
47
+ }
48
+ /** Collect every available `close` hook in deterministic order. */
49
+ function collectCloses(sources) {
50
+ const entries = [];
51
+ pushAll(entries, sources, "close");
52
+ return entries;
53
+ }
54
+ function messageOf(reason) {
55
+ return reason instanceof Error ? reason.message : String(reason);
56
+ }
57
+ function toFailure(entry, reason) {
58
+ return {
59
+ category: entry.category,
60
+ kind: entry.kind,
61
+ name: entry.name,
62
+ error: messageOf(reason)
63
+ };
64
+ }
65
+ function toResult(entries, outcomes, logger, verb) {
66
+ const failures = [];
67
+ for (const [index, entry] of entries.entries()) {
68
+ const outcome = outcomes[index];
69
+ if (outcome?.status === "rejected") failures.push(toFailure(entry, outcome.reason));
70
+ }
71
+ if (failures.length > 0) logger.error({ failures }, `adapter ${verb} failed`);
72
+ else logger.info({ count: entries.length }, `adapters ${verb}d`);
73
+ return {
74
+ ok: failures.length === 0,
75
+ failures
76
+ };
77
+ }
78
+ /** Run init hooks concurrently; failures are collected, never thrown. */
79
+ async function runAdapterInits(entries, ctx, logger) {
80
+ return toResult(entries, await Promise.allSettled(entries.map(async (entry) => {
81
+ await entry.run(ctx);
82
+ })), logger, "init");
83
+ }
84
+ /** Run close hooks concurrently; failures are collected, never thrown. */
85
+ async function runAdapterCloses(entries, ctx, logger) {
86
+ return toResult(entries, await Promise.allSettled(entries.map(async (entry) => {
87
+ await entry.run(ctx);
88
+ })), logger, "close");
89
+ }
90
+ //#endregion
91
+ export { AdapterLifecycleError, collectCloses, collectInits, runAdapterCloses, runAdapterInits };
92
+
93
+ //# sourceMappingURL=init.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.mjs","names":[],"sources":["../../src/adapters/init.ts"],"sourcesContent":["/**\n * Adapter lifecycle runner: collect and execute `init`/`close` hooks.\n *\n * `createShelfRouter` kicks `init` eagerly via `Promise.allSettled` (so one\n * slow adapter never starves the others) and gates the first request on the\n * shared result. `app.lifecycle.init()` re-runs for fail-fast startup;\n * `app.lifecycle.close()` fans out teardown. All hooks must be idempotent.\n */\nimport type { Logger } from \"pino\";\nimport type { AuthAdapter } from \"./auth.ts\";\nimport type { CaptureQueue } from \"./capture-queue.ts\";\nimport type { CaptureRunner } from \"./capture-runner.ts\";\nimport type { DatabaseAdapter } from \"./database.ts\";\nimport type { GitHostProvider } from \"./git-host/index.ts\";\nimport type { AdapterInitContext, AdapterLifecycle, AdapterMetadata } from \"./metadata.ts\";\nimport type { StorageAdapter } from \"./storage.ts\";\n\n/** Adapters that can take part in lifecycle runs. */\nexport interface AdapterInitSources {\n database: DatabaseAdapter;\n storage: StorageAdapter;\n captureRunner?: CaptureRunner;\n captureQueue?: CaptureQueue;\n auth?: AuthAdapter;\n gitHosts?: GitHostProvider[];\n}\n\ninterface LifecycleCarrier {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\n/** One collected hook bound to its adapter identity. */\nexport interface HookEntry {\n readonly category: string;\n readonly kind: string;\n readonly name: string;\n readonly run: (ctx: AdapterInitContext) => Promise<void>;\n}\n\n/** One failed hook, with its adapter identity attached. */\nexport interface AdapterInitFailure {\n readonly category: string;\n readonly kind: string;\n readonly name: string;\n readonly error: string;\n}\n\n/** Settled outcome of a lifecycle run (never rejects — use `ok`). */\nexport interface AdapterInitResult {\n readonly ok: boolean;\n readonly failures: AdapterInitFailure[];\n}\n\n/** Lifecycle phase a run covers. */\nexport type LifecyclePhase = \"init\" | \"close\";\n\n/** Thrown when an `app.lifecycle.init()` or `close()` run has failures. */\nexport class AdapterLifecycleError extends Error {\n readonly phase: LifecyclePhase;\n readonly failures: AdapterInitFailure[];\n constructor(phase: LifecyclePhase, failures: AdapterInitFailure[]) {\n super(`Adapter ${phase} failed: ${failures.map((f) => `${f.category}/${f.kind}`).join(\", \")}`);\n this.name = \"AdapterLifecycleError\";\n this.phase = phase;\n this.failures = failures;\n }\n}\n\nfunction hookEntry(adapter: LifecycleCarrier, hook: \"init\" | \"close\"): HookEntry | null {\n const fn = adapter.lifecycle?.[hook];\n if (!fn) {\n return null;\n }\n const { category, kind, name } = adapter.metadata;\n return {\n category,\n kind,\n name,\n run: async (ctx) => {\n await fn(ctx);\n },\n };\n}\n\nfunction pushHook(\n entries: HookEntry[],\n adapter: LifecycleCarrier | undefined,\n hook: \"init\" | \"close\",\n): void {\n if (!adapter) {\n return;\n }\n const entry = hookEntry(adapter, hook);\n if (entry) {\n entries.push(entry);\n }\n}\n\nfunction pushAll(entries: HookEntry[], sources: AdapterInitSources, hook: \"init\" | \"close\"): void {\n pushHook(entries, sources.database, hook);\n pushHook(entries, sources.storage, hook);\n pushHook(entries, sources.captureRunner, hook);\n pushHook(entries, sources.captureQueue, hook);\n pushHook(entries, sources.auth, hook);\n for (const provider of sources.gitHosts ?? []) {\n pushHook(entries, provider, hook);\n }\n}\n\n/** Collect every available `init` hook in deterministic order. */\nexport function collectInits(sources: AdapterInitSources): HookEntry[] {\n const entries: HookEntry[] = [];\n pushAll(entries, sources, \"init\");\n return entries;\n}\n\n/** Collect every available `close` hook in deterministic order. */\nexport function collectCloses(sources: AdapterInitSources): HookEntry[] {\n const entries: HookEntry[] = [];\n pushAll(entries, sources, \"close\");\n return entries;\n}\n\nfunction messageOf(reason: unknown): string {\n return reason instanceof Error ? reason.message : String(reason);\n}\n\nfunction toFailure(entry: HookEntry, reason: unknown): AdapterInitFailure {\n return { category: entry.category, kind: entry.kind, name: entry.name, error: messageOf(reason) };\n}\n\nfunction toResult(\n entries: HookEntry[],\n outcomes: PromiseSettledResult<void>[],\n logger: Logger,\n verb: string,\n): AdapterInitResult {\n const failures: AdapterInitFailure[] = [];\n for (const [index, entry] of entries.entries()) {\n const outcome: PromiseSettledResult<void> | undefined = outcomes[index];\n if (outcome?.status === \"rejected\") {\n failures.push(toFailure(entry, outcome.reason));\n }\n }\n if (failures.length > 0) {\n logger.error({ failures }, `adapter ${verb} failed`);\n } else {\n logger.info({ count: entries.length }, `adapters ${verb}d`);\n }\n return { ok: failures.length === 0, failures };\n}\n\n/** Run init hooks concurrently; failures are collected, never thrown. */\nexport async function runAdapterInits(\n entries: HookEntry[],\n ctx: AdapterInitContext,\n logger: Logger,\n): Promise<AdapterInitResult> {\n const outcomes = await Promise.allSettled(\n entries.map(async (entry) => {\n await entry.run(ctx);\n }),\n );\n return toResult(entries, outcomes, logger, \"init\");\n}\n\n/** Run close hooks concurrently; failures are collected, never thrown. */\nexport async function runAdapterCloses(\n entries: HookEntry[],\n ctx: AdapterInitContext,\n logger: Logger,\n): Promise<AdapterInitResult> {\n const outcomes = await Promise.allSettled(\n entries.map(async (entry) => {\n await entry.run(ctx);\n }),\n );\n return toResult(entries, outcomes, logger, \"close\");\n}\n"],"mappings":";;;;;AA0DA,IAAa,wBAAb,cAA2C,MAAM;CAC/C;CACA;CACA,YAAY,OAAuB,UAAgC;EACjE,MAAM,WAAW,MAAM,WAAW,SAAS,KAAK,MAAM,GAAG,EAAE,SAAS,GAAG,EAAE,MAAM,CAAC,CAAC,KAAK,IAAI,GAAG;EAC7F,KAAK,OAAO;EACZ,KAAK,QAAQ;EACb,KAAK,WAAW;CAClB;AACF;AAEA,SAAS,UAAU,SAA2B,MAA0C;CACtF,MAAM,KAAK,QAAQ,YAAY;CAC/B,IAAI,CAAC,IACH,OAAO;CAET,MAAM,EAAE,UAAU,MAAM,SAAS,QAAQ;CACzC,OAAO;EACL;EACA;EACA;EACA,KAAK,OAAO,QAAQ;GAClB,MAAM,GAAG,GAAG;EACd;CACF;AACF;AAEA,SAAS,SACP,SACA,SACA,MACM;CACN,IAAI,CAAC,SACH;CAEF,MAAM,QAAQ,UAAU,SAAS,IAAI;CACrC,IAAI,OACF,QAAQ,KAAK,KAAK;AAEtB;AAEA,SAAS,QAAQ,SAAsB,SAA6B,MAA8B;CAChG,SAAS,SAAS,QAAQ,UAAU,IAAI;CACxC,SAAS,SAAS,QAAQ,SAAS,IAAI;CACvC,SAAS,SAAS,QAAQ,eAAe,IAAI;CAC7C,SAAS,SAAS,QAAQ,cAAc,IAAI;CAC5C,SAAS,SAAS,QAAQ,MAAM,IAAI;CACpC,KAAK,MAAM,YAAY,QAAQ,YAAY,CAAC,GAC1C,SAAS,SAAS,UAAU,IAAI;AAEpC;;AAGA,SAAgB,aAAa,SAA0C;CACrE,MAAM,UAAuB,CAAC;CAC9B,QAAQ,SAAS,SAAS,MAAM;CAChC,OAAO;AACT;;AAGA,SAAgB,cAAc,SAA0C;CACtE,MAAM,UAAuB,CAAC;CAC9B,QAAQ,SAAS,SAAS,OAAO;CACjC,OAAO;AACT;AAEA,SAAS,UAAU,QAAyB;CAC1C,OAAO,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM;AACjE;AAEA,SAAS,UAAU,OAAkB,QAAqC;CACxE,OAAO;EAAE,UAAU,MAAM;EAAU,MAAM,MAAM;EAAM,MAAM,MAAM;EAAM,OAAO,UAAU,MAAM;CAAE;AAClG;AAEA,SAAS,SACP,SACA,UACA,QACA,MACmB;CACnB,MAAM,WAAiC,CAAC;CACxC,KAAK,MAAM,CAAC,OAAO,UAAU,QAAQ,QAAQ,GAAG;EAC9C,MAAM,UAAkD,SAAS;EACjE,IAAI,SAAS,WAAW,YACtB,SAAS,KAAK,UAAU,OAAO,QAAQ,MAAM,CAAC;CAElD;CACA,IAAI,SAAS,SAAS,GACpB,OAAO,MAAM,EAAE,SAAS,GAAG,WAAW,KAAK,QAAQ;MAEnD,OAAO,KAAK,EAAE,OAAO,QAAQ,OAAO,GAAG,YAAY,KAAK,EAAE;CAE5D,OAAO;EAAE,IAAI,SAAS,WAAW;EAAG;CAAS;AAC/C;;AAGA,eAAsB,gBACpB,SACA,KACA,QAC4B;CAM5B,OAAO,SAAS,SAAS,MALF,QAAQ,WAC7B,QAAQ,IAAI,OAAO,UAAU;EAC3B,MAAM,MAAM,IAAI,GAAG;CACrB,CAAC,CACH,GACmC,QAAQ,MAAM;AACnD;;AAGA,eAAsB,iBACpB,SACA,KACA,QAC4B;CAM5B,OAAO,SAAS,SAAS,MALF,QAAQ,WAC7B,QAAQ,IAAI,OAAO,UAAU;EAC3B,MAAM,MAAM,IAAI,GAAG;CACrB,CAAC,CACH,GACmC,QAAQ,OAAO;AACpD"}
@@ -0,0 +1,83 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { ShelfConfig } from "../config.mjs";
4
+ import { Logger } from "../logger.mjs";
5
+ import { z } from "zod";
6
+ //#region src/adapters/metadata.d.ts
7
+ /**
8
+ * Adapter family — which concern an adapter supplies.
9
+ *
10
+ * Distinct from `kind` (the implementation, e.g. "sqlite" vs "turso"):
11
+ * `category` tells a reader which slot the adapter fills without knowing
12
+ * which adapter instance supplied the metadata.
13
+ */
14
+ type AdapterCategory = "database" | "storage" | "auth" | "capture-runner" | "capture-queue" | "git-host";
15
+ /** Common adapter identity — every adapter instance exposes this. */
16
+ interface AdapterMetadata {
17
+ /** Human label for UI/logs. */
18
+ readonly name: string;
19
+ /** Package version injected at build via __PKG_VERSION__. */
20
+ readonly version: string;
21
+ /** Optional short description. */
22
+ readonly description?: string;
23
+ /** Machine key for the implementation (e.g. "sqlite", "local", "s3", "oauth", "playwright"). */
24
+ readonly kind: string;
25
+ /** Adapter family — which concern this adapter supplies. */
26
+ readonly category: AdapterCategory;
27
+ }
28
+ /** Git-specific metadata extension (adds logo + validation schema). */
29
+ interface GitAdapterMetadata extends AdapterMetadata {
30
+ readonly category: "git-host";
31
+ readonly logo?: string;
32
+ readonly schema: z.ZodType;
33
+ }
34
+ /** Context passed to an adapter's `init` on server startup. */
35
+ interface AdapterInitContext {
36
+ /** Resolved shelf configuration (scratchDir, secret, …). Never log the secret. */
37
+ readonly config: ShelfConfig;
38
+ /** Scoped logger for init-time diagnostics. */
39
+ readonly logger: Logger;
40
+ }
41
+ /** Result of an adapter's ongoing health probe. */
42
+ interface AdapterHealth {
43
+ /** Whether the adapter is currently usable. */
44
+ readonly ok: boolean;
45
+ /** Probe latency in milliseconds, when measured. */
46
+ readonly latencyMs?: number;
47
+ /** Short human-readable detail (must not contain secrets). */
48
+ readonly detail?: string;
49
+ }
50
+ /**
51
+ * Optional lifecycle every adapter can expose.
52
+ *
53
+ * Grouped in a sub-object (rather than flattened onto each adapter) so
54
+ * future hooks (`drain?`, `reinit?`, …) land in one place. All hooks must
55
+ * be idempotent — the router may run them more than once per process.
56
+ */
57
+ interface AdapterLifecycle {
58
+ /**
59
+ * One-shot setup: migrations, directory creation, credential validation.
60
+ * Throw when something required is missing.
61
+ */
62
+ init?: (ctx: AdapterInitContext) => Promise<void>;
63
+ /** Teardown: close clients and handles. Must tolerate repeated calls. */
64
+ close?: () => Promise<void>;
65
+ /** Ongoing liveness probe (cheap: SELECT 1, access(), head-bucket). */
66
+ health?: () => Promise<AdapterHealth>;
67
+ }
68
+ /**
69
+ * Shared adapter base.
70
+ *
71
+ * Every adapter interface extends this (narrowing `category` via `Extra`)
72
+ * instead of redeclaring `metadata`. `kind` stays an open string so
73
+ * third-party implementations are never blocked by a closed union.
74
+ */
75
+ interface Adapter<Extra extends object = Record<string, unknown>> {
76
+ /** Adapter identity (mandatory). */
77
+ readonly metadata: AdapterMetadata & Extra;
78
+ /** Optional lifecycle hooks. */
79
+ readonly lifecycle?: AdapterLifecycle;
80
+ }
81
+ //#endregion
82
+ export { Adapter, AdapterCategory, AdapterHealth, AdapterInitContext, AdapterLifecycle, AdapterMetadata, GitAdapterMetadata };
83
+ //# sourceMappingURL=metadata.d.mts.map
@@ -0,0 +1,4 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ export {};
@@ -1,12 +1,13 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { AdapterMetadata } from "../adapters/metadata.mjs";
3
+ import { Adapter } from "./metadata.mjs";
4
+ import { Readable } from "node:stream";
4
5
  import { Buffer } from "node:buffer";
5
6
  //#region src/adapters/storage.d.ts
6
7
  /** Blob storage abstraction for screenshots, diffs and Storybook archives. */
7
- interface StorageAdapter {
8
- /** Adapter identity. */
9
- readonly metadata?: AdapterMetadata;
8
+ interface StorageAdapter extends Adapter<{
9
+ readonly category: "storage";
10
+ }> {
10
11
  /** Read the bytes stored at `path`. */
11
12
  read(path: string): Promise<Buffer>;
12
13
  /** Write `data` to `path`, creating parent directories as needed. */
@@ -17,6 +18,16 @@ interface StorageAdapter {
17
18
  exists(path: string): Promise<boolean>;
18
19
  /** List object paths under the given key prefix. */
19
20
  list(prefix: string): Promise<string[]>;
21
+ /**
22
+ * Stream `stream` to `path`, creating parent directories as needed.
23
+ * Resolves when the stream is fully consumed and persisted.
24
+ */
25
+ writeStream(path: string, stream: Readable): Promise<void>;
26
+ /**
27
+ * Open a stream for the bytes stored at `path`.
28
+ * Rejects when no object exists at `path`.
29
+ */
30
+ readStream(path: string): Promise<Readable>;
20
31
  }
21
32
  //#endregion
22
33
  export { StorageAdapter };
@@ -1,8 +1,8 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { hmacSha256 } from "../utils/hash.mjs";
5
4
  import { WebhookModel } from "../models/webhook.mjs";
5
+ import { hmacSha256 } from "../utils/hash.mjs";
6
6
  //#region src/adapters/webhook-events.ts
7
7
  async function sendWebhook(url, secret, event) {
8
8
  const body = JSON.stringify(event);
@@ -17,8 +17,18 @@ async function sendWebhook(url, secret, event) {
17
17
  body
18
18
  });
19
19
  }
20
- async function emitWebhookEvent(db, projectId, event, data) {
21
- const webhooks = await new WebhookModel(db).list(projectId);
20
+ /**
21
+ * Deliver an event to every subscribed webhook of a project.
22
+ *
23
+ * @param db - Database adapter for loading subscriptions.
24
+ * @param projectId - Project whose webhooks receive the event.
25
+ * @param event - Event name (e.g. "baseline:created").
26
+ * @param data - Event payload.
27
+ * @param secret - Server secret for decrypting webhook secrets (in memory only).
28
+ */
29
+ async function emitWebhookEvent(db, projectId, event, data, secret) {
30
+ const webhookModel = new WebhookModel(db, secret);
31
+ const webhooks = await webhookModel.list(projectId);
22
32
  const eventPayload = {
23
33
  event,
24
34
  projectId,
@@ -28,8 +38,14 @@ async function emitWebhookEvent(db, projectId, event, data) {
28
38
  await Promise.allSettled(webhooks.map(async (webhook) => {
29
39
  const events = WebhookModel.eventsOf(webhook);
30
40
  if (events.length > 0 && !events.includes(event)) return;
41
+ let plaintext;
42
+ try {
43
+ plaintext = webhookModel.decryptSecret(webhook);
44
+ } catch {
45
+ return;
46
+ }
31
47
  try {
32
- await sendWebhook(webhook.url, webhook.secret, eventPayload);
48
+ await sendWebhook(webhook.url, plaintext, eventPayload);
33
49
  } catch {}
34
50
  }));
35
51
  }
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-events.mjs","names":[],"sources":["../../src/adapters/webhook-events.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"./database.ts\";\nimport { WebhookModel } from \"../models/webhook.ts\";\nimport { hmacSha256 } from \"../utils/hash.ts\";\n\nexport interface WebhookEvent {\n event: string;\n projectId: string;\n data: Record<string, unknown>;\n timestamp: string;\n}\n\nasync function sendWebhook(url: string, secret: string, event: WebhookEvent): Promise<void> {\n const body = JSON.stringify(event);\n const signature = hmacSha256(secret, body);\n await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-StoryShelf-Event\": event.event,\n \"X-StoryShelf-Signature\": signature,\n },\n body,\n });\n}\n\nexport async function emitWebhookEvent(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n): Promise<void> {\n const webhookModel = new WebhookModel(db);\n const webhooks = await webhookModel.list(projectId);\n const eventPayload: WebhookEvent = {\n event,\n projectId,\n data,\n timestamp: new Date().toISOString(),\n };\n\n await Promise.allSettled(\n webhooks.map(async (webhook) => {\n const events = WebhookModel.eventsOf(webhook);\n if (events.length > 0 && !events.includes(event)) {\n return;\n }\n try {\n await sendWebhook(webhook.url, webhook.secret, eventPayload);\n } catch {\n // Webhook delivery failures are non-fatal\n }\n }),\n );\n}\n"],"mappings":";;;;;;AAWA,eAAe,YAAY,KAAa,QAAgB,OAAoC;CAC1F,MAAM,OAAO,KAAK,UAAU,KAAK;CACjC,MAAM,YAAY,WAAW,QAAQ,IAAI;CACzC,MAAM,MAAM,KAAK;EACf,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,sBAAsB,MAAM;GAC5B,0BAA0B;EAC5B;EACA;CACF,CAAC;AACH;AAEA,eAAsB,iBACpB,IACA,WACA,OACA,MACe;CAEf,MAAM,WAAW,MAAM,IADE,aAAa,EACJ,CAAC,CAAC,KAAK,SAAS;CAClD,MAAM,eAA6B;EACjC;EACA;EACA;EACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC;CAEA,MAAM,QAAQ,WACZ,SAAS,IAAI,OAAO,YAAY;EAC9B,MAAM,SAAS,aAAa,SAAS,OAAO;EAC5C,IAAI,OAAO,SAAS,KAAK,CAAC,OAAO,SAAS,KAAK,GAC7C;EAEF,IAAI;GACF,MAAM,YAAY,QAAQ,KAAK,QAAQ,QAAQ,YAAY;EAC7D,QAAQ,CAER;CACF,CAAC,CACH;AACF"}
1
+ {"version":3,"file":"webhook-events.mjs","names":[],"sources":["../../src/adapters/webhook-events.ts"],"sourcesContent":["import { WebhookModel } from \"../models/webhook.ts\";\nimport { hmacSha256 } from \"../utils/hash.ts\";\nimport type { DatabaseAdapter } from \"./database.ts\";\n\n/** Outbound webhook payload delivered to subscribers. */\nexport interface WebhookEvent {\n event: string;\n projectId: string;\n data: Record<string, unknown>;\n timestamp: string;\n}\n\nasync function sendWebhook(url: string, secret: string, event: WebhookEvent): Promise<void> {\n const body = JSON.stringify(event);\n const signature = hmacSha256(secret, body);\n await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-StoryShelf-Event\": event.event,\n \"X-StoryShelf-Signature\": signature,\n },\n body,\n });\n}\n\n/**\n * Deliver an event to every subscribed webhook of a project.\n *\n * @param db - Database adapter for loading subscriptions.\n * @param projectId - Project whose webhooks receive the event.\n * @param event - Event name (e.g. \"baseline:created\").\n * @param data - Event payload.\n * @param secret - Server secret for decrypting webhook secrets (in memory only).\n */\nexport async function emitWebhookEvent(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n secret: string | undefined,\n): Promise<void> {\n const webhookModel = new WebhookModel(db, secret);\n const webhooks = await webhookModel.list(projectId);\n const eventPayload: WebhookEvent = {\n event,\n projectId,\n data,\n timestamp: new Date().toISOString(),\n };\n\n await Promise.allSettled(\n webhooks.map(async (webhook) => {\n const events = WebhookModel.eventsOf(webhook);\n if (events.length > 0 && !events.includes(event)) {\n return;\n }\n let plaintext: string;\n try {\n plaintext = webhookModel.decryptSecret(webhook);\n } catch {\n // Undecryptable secret (e.g. rotated server SECRET) — skip, don't leak\n return;\n }\n try {\n await sendWebhook(webhook.url, plaintext, eventPayload);\n } catch {\n // Webhook delivery failures are non-fatal\n }\n }),\n );\n}\n"],"mappings":";;;;;;AAYA,eAAe,YAAY,KAAa,QAAgB,OAAoC;CAC1F,MAAM,OAAO,KAAK,UAAU,KAAK;CACjC,MAAM,YAAY,WAAW,QAAQ,IAAI;CACzC,MAAM,MAAM,KAAK;EACf,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,sBAAsB,MAAM;GAC5B,0BAA0B;EAC5B;EACA;CACF,CAAC;AACH;;;;;;;;;;AAWA,eAAsB,iBACpB,IACA,WACA,OACA,MACA,QACe;CACf,MAAM,eAAe,IAAI,aAAa,IAAI,MAAM;CAChD,MAAM,WAAW,MAAM,aAAa,KAAK,SAAS;CAClD,MAAM,eAA6B;EACjC;EACA;EACA;EACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC;CAEA,MAAM,QAAQ,WACZ,SAAS,IAAI,OAAO,YAAY;EAC9B,MAAM,SAAS,aAAa,SAAS,OAAO;EAC5C,IAAI,OAAO,SAAS,KAAK,CAAC,OAAO,SAAS,KAAK,GAC7C;EAEF,IAAI;EACJ,IAAI;GACF,YAAY,aAAa,cAAc,OAAO;EAChD,QAAQ;GAEN;EACF;EACA,IAAI;GACF,MAAM,YAAY,QAAQ,KAAK,WAAW,YAAY;EACxD,QAAQ,CAER;CACF,CAAC,CACH;AACF"}
@@ -1,6 +1,7 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
3
  //#region src/capture/adapter.d.ts
4
+ /** Per-story capture parameters from the Storybook index. */
4
5
  interface StoryParameters {
5
6
  disableSnapshot?: boolean;
6
7
  delay?: number;
@@ -8,6 +9,10 @@ interface StoryParameters {
8
9
  pauseAnimationAtEnd?: boolean;
9
10
  flakyTest?: boolean;
10
11
  }
12
+ /** Return whether a story is marked flaky (failures stay non-blocking). */
13
+ declare function isFlakyStory(entry: Pick<StoryEntry, "tags" | "parameters">): boolean;
14
+ /** Return whether a story is excluded from snapshot capture. */
15
+ declare function isDisabledStory(entry: Pick<StoryEntry, "tags" | "parameters">): boolean;
11
16
  /** A single discoverable story or docs entry within a Storybook. */
12
17
  interface StoryEntry {
13
18
  id: string;
@@ -31,7 +36,8 @@ interface Viewport {
31
36
  width: number;
32
37
  height: number;
33
38
  }
39
+ /** Default viewport used when none is configured. */
34
40
  declare const DEFAULT_VIEWPORTS: Viewport[];
35
41
  //#endregion
36
- export { DEFAULT_VIEWPORTS, StoryEntry, StoryParameters, StorySourceAdapter, Viewport };
42
+ export { DEFAULT_VIEWPORTS, StoryEntry, StoryParameters, StorySourceAdapter, Viewport, isDisabledStory, isFlakyStory };
37
43
  //# sourceMappingURL=adapter.d.mts.map
@@ -2,12 +2,14 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  //#region src/capture/adapter.ts
5
+ /** Return whether a story is marked flaky (failures stay non-blocking). */
5
6
  function isFlakyStory(entry) {
6
7
  if (entry.parameters?.flakyTest) return true;
7
8
  const tags = entry.tags ?? [];
8
9
  for (const t of tags) if (t.toLowerCase() === "flaky-test") return true;
9
10
  return false;
10
11
  }
12
+ /** Return whether a story is excluded from snapshot capture. */
11
13
  function isDisabledStory(entry) {
12
14
  if (entry.parameters?.disableSnapshot) return true;
13
15
  const tags = entry.tags ?? [];
@@ -17,6 +19,7 @@ function isDisabledStory(entry) {
17
19
  }
18
20
  return false;
19
21
  }
22
+ /** Default viewport used when none is configured. */
20
23
  const DEFAULT_VIEWPORTS = [{
21
24
  name: "desktop",
22
25
  width: 1280,
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.mjs","names":[],"sources":["../../src/capture/adapter.ts"],"sourcesContent":["export interface StoryParameters {\n disableSnapshot?: boolean;\n delay?: number;\n diffThreshold?: number;\n pauseAnimationAtEnd?: boolean;\n flakyTest?: boolean;\n}\n\nexport function isFlakyStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.flakyTest) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n if (t.toLowerCase() === \"flaky-test\") return true;\n }\n return false;\n}\n\nexport function isDisabledStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.disableSnapshot) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n const lower = t.toLowerCase();\n if (lower === \"skip\" || lower === \"disable\" || lower === \"disable-snapshot\") return true;\n }\n return false;\n}\n\n/** A single discoverable story or docs entry within a Storybook. */\nexport interface StoryEntry {\n id: string;\n title: string;\n name: string;\n importPath?: string;\n tags?: string[];\n type: \"story\" | \"docs\";\n parameters?: StoryParameters;\n}\n\n/** Adapter that discovers and renders stories from a Storybook build. */\nexport interface StorySourceAdapter {\n name: string;\n discover(source: string): Promise<StoryEntry[]>;\n buildUrl(baseUrl: string, storyId: string): string;\n screenshotSelector?: string;\n}\n\n/** A viewport size at which stories are captured. */\nexport interface Viewport {\n name: string;\n width: number;\n height: number;\n}\n\nexport const DEFAULT_VIEWPORTS: Viewport[] = [{ name: \"desktop\", width: 1280, height: 720 }];\n"],"mappings":";;;;AAQA,SAAgB,aAAa,OAAyD;CACpF,IAAI,MAAM,YAAY,WAAW,OAAO;CACxC,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MACd,IAAI,EAAE,YAAY,MAAM,cAAc,OAAO;CAE/C,OAAO;AACT;AAEA,SAAgB,gBAAgB,OAAyD;CACvF,IAAI,MAAM,YAAY,iBAAiB,OAAO;CAC9C,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,QAAQ,EAAE,YAAY;EAC5B,IAAI,UAAU,UAAU,UAAU,aAAa,UAAU,oBAAoB,OAAO;CACtF;CACA,OAAO;AACT;AA4BA,MAAa,oBAAgC,CAAC;CAAE,MAAM;CAAW,OAAO;CAAM,QAAQ;AAAI,CAAC"}
1
+ {"version":3,"file":"adapter.mjs","names":[],"sources":["../../src/capture/adapter.ts"],"sourcesContent":["/** Per-story capture parameters from the Storybook index. */\nexport interface StoryParameters {\n disableSnapshot?: boolean;\n delay?: number;\n diffThreshold?: number;\n pauseAnimationAtEnd?: boolean;\n flakyTest?: boolean;\n}\n\n/** Return whether a story is marked flaky (failures stay non-blocking). */\nexport function isFlakyStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.flakyTest) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n if (t.toLowerCase() === \"flaky-test\") return true;\n }\n return false;\n}\n\n/** Return whether a story is excluded from snapshot capture. */\nexport function isDisabledStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.disableSnapshot) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n const lower = t.toLowerCase();\n if (lower === \"skip\" || lower === \"disable\" || lower === \"disable-snapshot\") return true;\n }\n return false;\n}\n\n/** A single discoverable story or docs entry within a Storybook. */\nexport interface StoryEntry {\n id: string;\n title: string;\n name: string;\n importPath?: string;\n tags?: string[];\n type: \"story\" | \"docs\";\n parameters?: StoryParameters;\n}\n\n/** Adapter that discovers and renders stories from a Storybook build. */\nexport interface StorySourceAdapter {\n name: string;\n discover(source: string): Promise<StoryEntry[]>;\n buildUrl(baseUrl: string, storyId: string): string;\n screenshotSelector?: string;\n}\n\n/** A viewport size at which stories are captured. */\nexport interface Viewport {\n name: string;\n width: number;\n height: number;\n}\n\n/** Default viewport used when none is configured. */\nexport const DEFAULT_VIEWPORTS: Viewport[] = [{ name: \"desktop\", width: 1280, height: 720 }];\n"],"mappings":";;;;;AAUA,SAAgB,aAAa,OAAyD;CACpF,IAAI,MAAM,YAAY,WAAW,OAAO;CACxC,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MACd,IAAI,EAAE,YAAY,MAAM,cAAc,OAAO;CAE/C,OAAO;AACT;;AAGA,SAAgB,gBAAgB,OAAyD;CACvF,IAAI,MAAM,YAAY,iBAAiB,OAAO;CAC9C,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,QAAQ,EAAE,YAAY;EAC5B,IAAI,UAAU,UAAU,UAAU,aAAa,UAAU,oBAAoB,OAAO;CACtF;CACA,OAAO;AACT;;AA6BA,MAAa,oBAAgC,CAAC;CAAE,MAAM;CAAW,OAAO;CAAM,QAAQ;AAAI,CAAC"}
@@ -0,0 +1,150 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { BuildModel } from "../models/build.mjs";
5
+ import { ProjectModel } from "../models/project.mjs";
6
+ import { executeCaptureJob } from "./orchestrator.mjs";
7
+ import { hasApprovedBuildForSha, isAlreadyMerged } from "./skip-checks.mjs";
8
+ import { postStatusesForBuild } from "./status-fanout.mjs";
9
+ //#region src/capture/dispatch.ts
10
+ async function postPending(deps, project, url, sha) {
11
+ await postStatusesForBuild({
12
+ db: deps.db,
13
+ project,
14
+ sha,
15
+ status: "pending",
16
+ url,
17
+ providers: deps.gitHosts,
18
+ secret: deps.secret,
19
+ logger: deps.logger
20
+ }).catch((error) => {
21
+ deps.logger.error({ err: error }, "failed to post pending status");
22
+ });
23
+ }
24
+ async function postTerminal(deps, project, url, sha, status) {
25
+ await postStatusesForBuild({
26
+ db: deps.db,
27
+ project,
28
+ sha,
29
+ status,
30
+ url,
31
+ providers: deps.gitHosts,
32
+ secret: deps.secret,
33
+ logger: deps.logger
34
+ }).catch((error) => {
35
+ deps.logger.error({ err: error }, "failed to post terminal status");
36
+ });
37
+ }
38
+ async function approveWithoutCapture(ctx, reason) {
39
+ const { deps, build, project, pendingUrl } = ctx;
40
+ deps.logger.info({
41
+ buildId: build.id,
42
+ sha: build.gitSha,
43
+ branch: build.gitBranch
44
+ }, reason);
45
+ await new BuildModel(deps.db).setStatus(build.id, "approved").catch(() => {});
46
+ await postTerminal(deps, project, pendingUrl, build.gitSha, "success").catch(() => {});
47
+ }
48
+ async function maybeSkipMerged(ctx) {
49
+ const { deps, build, project } = ctx;
50
+ if (build.isDefault) return false;
51
+ if (await isAlreadyMerged({
52
+ providers: deps.gitHosts,
53
+ sha: build.gitSha,
54
+ branch: build.gitBranch,
55
+ secret: deps.secret,
56
+ db: deps.db,
57
+ projectId: project.id,
58
+ logger: deps.logger
59
+ }).catch(() => false)) {
60
+ await approveWithoutCapture(ctx, "skipping capture — already merged");
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+ async function maybeSkipDuplicate(ctx) {
66
+ const { deps, build, project } = ctx;
67
+ if (await hasApprovedBuildForSha(deps.db, project.id, build.gitSha, build.id)) {
68
+ await approveWithoutCapture(ctx, "skipping capture — duplicate sha already approved");
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ /** Map a terminal build status to its check-run state. */
74
+ function terminalState(status) {
75
+ if (status === "approved") return "success";
76
+ if (status === "rejected" || status === "failed") return "failure";
77
+ return null;
78
+ }
79
+ /** Post the terminal status for a finished capture. */
80
+ async function reportTerminalStatus(ctx, buildId) {
81
+ const { deps, build, project, pendingUrl } = ctx;
82
+ const terminal = terminalState((await new BuildModel(deps.db).get(buildId))?.status);
83
+ if (terminal) await postTerminal(deps, project, pendingUrl, build.gitSha, terminal);
84
+ }
85
+ async function runAndReport(ctx, job) {
86
+ const { deps, build, project, pendingUrl } = ctx;
87
+ try {
88
+ await executeCaptureJob({
89
+ buildId: job.buildId,
90
+ reqId: job.reqId
91
+ }, deps.jobOptions);
92
+ await reportTerminalStatus(ctx, job.buildId);
93
+ } catch (error) {
94
+ await postStatusesForBuild({
95
+ db: deps.db,
96
+ project,
97
+ sha: build.gitSha,
98
+ status: "failure",
99
+ url: pendingUrl,
100
+ providers: deps.gitHosts,
101
+ secret: deps.secret,
102
+ logger: deps.logger
103
+ }).catch(() => {});
104
+ throw error;
105
+ }
106
+ }
107
+ /** Load the build + project for a job, running orphan jobs directly. */
108
+ async function loadJobTarget(deps, job) {
109
+ const build = await new BuildModel(deps.db).get(job.buildId);
110
+ if (!build) {
111
+ await executeCaptureJob({
112
+ buildId: job.buildId,
113
+ reqId: job.reqId
114
+ }, deps.jobOptions);
115
+ return null;
116
+ }
117
+ const project = await new ProjectModel(deps.db).get(build.projectId);
118
+ if (!project) {
119
+ await executeCaptureJob({
120
+ buildId: job.buildId,
121
+ reqId: job.reqId
122
+ }, deps.jobOptions);
123
+ return null;
124
+ }
125
+ return {
126
+ build,
127
+ project
128
+ };
129
+ }
130
+ /** Build the queue's job runner: post pending, apply skip rules, capture, report. */
131
+ function createDispatchJob(deps) {
132
+ return async (job) => {
133
+ const target = await loadJobTarget(deps, job);
134
+ if (!target) return;
135
+ const pendingUrl = `/projects/${target.project.slug}/builds/${job.buildId}`;
136
+ await postPending(deps, target.project, pendingUrl, target.build.gitSha);
137
+ const ctx = {
138
+ deps,
139
+ build: target.build,
140
+ project: target.project,
141
+ pendingUrl
142
+ };
143
+ if (await maybeSkipMerged(ctx) || await maybeSkipDuplicate(ctx)) return;
144
+ await runAndReport(ctx, job);
145
+ };
146
+ }
147
+ //#endregion
148
+ export { createDispatchJob };
149
+
150
+ //# sourceMappingURL=dispatch.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatch.mjs","names":[],"sources":["../../src/capture/dispatch.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { GitHostProvider } from \"../adapters/git-host/index.ts\";\nimport type { Logger } from \"../logger.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { executeCaptureJob, type CaptureJobOptions } from \"./orchestrator.ts\";\nimport { hasApprovedBuildForSha, isAlreadyMerged } from \"./skip-checks.ts\";\nimport { postStatusesForBuild } from \"./status-fanout.ts\";\n\n/** Dependencies for building the capture queue's job runner. */\nexport interface DispatchDeps {\n db: DatabaseAdapter;\n jobOptions: CaptureJobOptions;\n gitHosts: GitHostProvider[];\n secret: string | undefined;\n logger: Logger;\n}\n\n/** A queued capture job. */\nexport interface CaptureDispatchJob {\n buildId: string;\n reqId?: string;\n}\n\ninterface JobContext {\n deps: DispatchDeps;\n build: Build;\n project: Project;\n pendingUrl: string;\n}\n\nasync function postPending(\n deps: DispatchDeps,\n project: Project,\n url: string,\n sha: string,\n): Promise<void> {\n await postStatusesForBuild({\n db: deps.db,\n project,\n sha,\n status: \"pending\",\n url,\n providers: deps.gitHosts,\n secret: deps.secret,\n logger: deps.logger,\n }).catch((error: unknown) => {\n deps.logger.error({ err: error }, \"failed to post pending status\");\n });\n}\n\nasync function postTerminal(\n deps: DispatchDeps,\n project: Project,\n url: string,\n sha: string,\n status: \"success\" | \"failure\",\n): Promise<void> {\n await postStatusesForBuild({\n db: deps.db,\n project,\n sha,\n status,\n url,\n providers: deps.gitHosts,\n secret: deps.secret,\n logger: deps.logger,\n }).catch((error: unknown) => {\n deps.logger.error({ err: error }, \"failed to post terminal status\");\n });\n}\n\nasync function approveWithoutCapture(ctx: JobContext, reason: string): Promise<void> {\n const { deps, build, project, pendingUrl } = ctx;\n deps.logger.info({ buildId: build.id, sha: build.gitSha, branch: build.gitBranch }, reason);\n await new BuildModel(deps.db).setStatus(build.id, \"approved\").catch(() => {}); // Intentionally empty — fire-and-forget\n await postTerminal(deps, project, pendingUrl, build.gitSha, \"success\").catch(() => {}); // Intentionally empty — fire-and-forget\n}\n\nasync function maybeSkipMerged(ctx: JobContext): Promise<boolean> {\n const { deps, build, project } = ctx;\n if (build.isDefault) {\n return false;\n }\n const merged = await isAlreadyMerged({\n providers: deps.gitHosts,\n sha: build.gitSha,\n branch: build.gitBranch,\n secret: deps.secret,\n db: deps.db,\n projectId: project.id,\n logger: deps.logger,\n }).catch(() => false);\n if (merged) {\n await approveWithoutCapture(ctx, \"skipping capture — already merged\");\n return true;\n }\n return false;\n}\n\nasync function maybeSkipDuplicate(ctx: JobContext): Promise<boolean> {\n const { deps, build, project } = ctx;\n const dup = await hasApprovedBuildForSha(deps.db, project.id, build.gitSha, build.id);\n if (dup) {\n await approveWithoutCapture(ctx, \"skipping capture — duplicate sha already approved\");\n return true;\n }\n return false;\n}\n\n/** Map a terminal build status to its check-run state. */\nfunction terminalState(status: string | undefined): \"success\" | \"failure\" | null {\n if (status === \"approved\") {\n return \"success\";\n }\n if (status === \"rejected\" || status === \"failed\") {\n return \"failure\";\n }\n return null;\n}\n\n/** Post the terminal status for a finished capture. */\nasync function reportTerminalStatus(ctx: JobContext, buildId: string): Promise<void> {\n const { deps, build, project, pendingUrl } = ctx;\n const updated = await new BuildModel(deps.db).get(buildId);\n const terminal = terminalState(updated?.status);\n if (terminal) {\n await postTerminal(deps, project, pendingUrl, build.gitSha, terminal);\n }\n}\n\nasync function runAndReport(ctx: JobContext, job: CaptureDispatchJob): Promise<void> {\n const { deps, build, project, pendingUrl } = ctx;\n try {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, deps.jobOptions);\n await reportTerminalStatus(ctx, job.buildId);\n } catch (error: unknown) {\n await postStatusesForBuild({\n db: deps.db,\n project,\n sha: build.gitSha,\n status: \"failure\",\n url: pendingUrl,\n providers: deps.gitHosts,\n secret: deps.secret,\n logger: deps.logger,\n }).catch(() => {\n // Ignore: status post failure already logged\n });\n throw error;\n }\n}\n\n/** Load the build + project for a job, running orphan jobs directly. */\nasync function loadJobTarget(\n deps: DispatchDeps,\n job: CaptureDispatchJob,\n): Promise<{ build: Build; project: Project } | null> {\n const builds = new BuildModel(deps.db);\n const build = await builds.get(job.buildId);\n if (!build) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, deps.jobOptions);\n return null;\n }\n const project = await new ProjectModel(deps.db).get(build.projectId);\n if (!project) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, deps.jobOptions);\n return null;\n }\n return { build, project };\n}\n\n/** Build the queue's job runner: post pending, apply skip rules, capture, report. */\nexport function createDispatchJob(deps: DispatchDeps): (job: CaptureDispatchJob) => Promise<void> {\n return async (job: CaptureDispatchJob): Promise<void> => {\n const target = await loadJobTarget(deps, job);\n if (!target) {\n return;\n }\n const pendingUrl = `/projects/${target.project.slug}/builds/${job.buildId}`;\n await postPending(deps, target.project, pendingUrl, target.build.gitSha);\n const ctx: JobContext = { deps, build: target.build, project: target.project, pendingUrl };\n if ((await maybeSkipMerged(ctx)) || (await maybeSkipDuplicate(ctx))) {\n return;\n }\n await runAndReport(ctx, job);\n };\n}\n"],"mappings":"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"}
@@ -6,6 +6,7 @@ import { DatabaseAdapter } from "../adapter/database.mjs";
6
6
  import { StorageAdapter } from "../adapter/storage.mjs";
7
7
  import { Logger } from "pino";
8
8
  //#region src/capture/orchestrator.d.ts
9
+ /** Inputs for running a capture job against a Storybook build. */
9
10
  interface CaptureJobOptions {
10
11
  db: DatabaseAdapter;
11
12
  storage: StorageAdapter;
@@ -13,7 +14,15 @@ interface CaptureJobOptions {
13
14
  scratchDir: string;
14
15
  viewports?: Viewport[];
15
16
  logger?: Logger;
17
+ /** Server secret for decrypting webhook secrets at send time. */
18
+ secret?: string | undefined;
16
19
  }
20
+ /**
21
+ * Run the full capture for a build: extract, render, persist, and finalize.
22
+ *
23
+ * @param input - Build id plus the originating request id.
24
+ * @param options - Adapters, scratch dir, viewports, and logger.
25
+ */
17
26
  declare function executeCaptureJob(input: {
18
27
  buildId: string;
19
28
  reqId?: string;
@@ -3,14 +3,17 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { BuildModel } from "../models/build.mjs";
5
5
  import { ProjectModel } from "../models/project.mjs";
6
- import { storybookDir, storybookZipPath } from "../utils/paths.mjs";
6
+ import { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from "./adapter.mjs";
7
7
  import { persistCapture } from "./pipeline.mjs";
8
+ import { extractStorybookToScratch, persistStorybookStatics } from "./statics.mjs";
8
9
  import { StorybookAdapter } from "./storybook.mjs";
9
- import { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from "./adapter.mjs";
10
- import { mkdir, readFile, readdir, rm } from "node:fs/promises";
11
- import { join, relative, resolve, sep } from "node:path";
12
- import AdmZip from "adm-zip";
13
10
  //#region src/capture/orchestrator.ts
11
+ /**
12
+ * Run the full capture for a build: extract, render, persist, and finalize.
13
+ *
14
+ * @param input - Build id plus the originating request id.
15
+ * @param options - Adapters, scratch dir, viewports, and logger.
16
+ */
14
17
  async function executeCaptureJob(input, options) {
15
18
  const builds = new BuildModel(options.db);
16
19
  const { build, project } = await loadTarget(options, input.buildId);
@@ -23,7 +26,7 @@ async function executeCaptureJob(input, options) {
23
26
  let extractedDir;
24
27
  try {
25
28
  const extractStart = performance.now();
26
- extractedDir = await extractStorybook(options, project.id, build.id);
29
+ extractedDir = await extractStorybookToScratch(options.storage, options.scratchDir, project.id, build.id);
27
30
  const extractDuration = performance.now() - extractStart;
28
31
  logger?.info({ durationMs: Math.round(extractDuration) }, "storybook extracted");
29
32
  const staticsStart = performance.now();
@@ -59,7 +62,8 @@ async function executeCaptureJob(input, options) {
59
62
  build,
60
63
  viewports,
61
64
  captures: result.captures,
62
- logger
65
+ logger,
66
+ secret: options.secret
63
67
  }, blockingFailed, flakyFailed);
64
68
  const persistDuration = performance.now() - persistStart;
65
69
  logger?.info({ durationMs: Math.round(persistDuration) }, "capture persisted");
@@ -87,43 +91,6 @@ async function loadTarget(options, buildId) {
87
91
  project
88
92
  };
89
93
  }
90
- function blockedTarget(root, entryName) {
91
- const candidate = resolve(join(root, entryName));
92
- return candidate !== root && !candidate.startsWith(root + sep);
93
- }
94
- function assertNoTraversal(zip, root) {
95
- for (const entry of zip.getEntries()) if (blockedTarget(root, entry.entryName)) throw new Error(`Blocked path traversal in uploaded Storybook: ${entry.entryName}`);
96
- }
97
- async function extractStorybook(options, projectId, buildId) {
98
- const targetDir = join(options.scratchDir, projectId, "builds", buildId, "storybook");
99
- const root = resolve(targetDir);
100
- const zip = new AdmZip(await options.storage.read(storybookZipPath(projectId, buildId)));
101
- assertNoTraversal(zip, root);
102
- await rm(targetDir, {
103
- recursive: true,
104
- force: true
105
- });
106
- await mkdir(targetDir, { recursive: true });
107
- zip.extractAllTo(targetDir, true);
108
- return targetDir;
109
- }
110
- async function walkFiles(dir) {
111
- const entries = await readdir(dir, { withFileTypes: true });
112
- return (await Promise.all(entries.map(async (entry) => {
113
- const full = join(dir, entry.name);
114
- if (entry.isDirectory()) return await walkFiles(full);
115
- return [full];
116
- }))).flat();
117
- }
118
- async function persistStorybookStatics(storage, sourceDir, projectId, buildId) {
119
- const root = resolve(sourceDir);
120
- const destinationPrefix = storybookDir(projectId, buildId);
121
- const files = await walkFiles(root);
122
- await Promise.all(files.map(async (file) => {
123
- const rel = relative(root, file);
124
- await storage.write(`${destinationPrefix}/${rel}`, await readFile(file));
125
- }));
126
- }
127
94
  //#endregion
128
95
  export { executeCaptureJob };
129
96