@storyshelf/core 0.1.0 → 0.1.3

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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -8
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/adapter/capture-runner.d.mts +4 -0
  5. package/dist/adapter/git-host/helpers.d.mts +1 -0
  6. package/dist/adapter/git-host/helpers.mjs +2 -1
  7. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  8. package/dist/adapter/storage.d.mts +1 -0
  9. package/dist/adapters/capture-queue.d.mts +2 -0
  10. package/dist/adapters/webhook-events.mjs +8 -0
  11. package/dist/adapters/webhook-events.mjs.map +1 -1
  12. package/dist/capture/adapter.d.mts +11 -1
  13. package/dist/capture/adapter.mjs +19 -1
  14. package/dist/capture/adapter.mjs.map +1 -1
  15. package/dist/capture/orchestrator.d.mts +7 -0
  16. package/dist/capture/orchestrator.mjs +43 -10
  17. package/dist/capture/orchestrator.mjs.map +1 -1
  18. package/dist/capture/pipeline.d.mts +5 -3
  19. package/dist/capture/pipeline.mjs +18 -6
  20. package/dist/capture/pipeline.mjs.map +1 -1
  21. package/dist/capture/queue.d.mts +2 -0
  22. package/dist/capture/queue.mjs +2 -1
  23. package/dist/capture/queue.mjs.map +1 -1
  24. package/dist/capture/status-fanout.mjs +1 -0
  25. package/dist/capture/status-fanout.mjs.map +1 -1
  26. package/dist/capture/storybook.d.mts +1 -0
  27. package/dist/capture/storybook.mjs +12 -3
  28. package/dist/capture/storybook.mjs.map +1 -1
  29. package/dist/config.d.mts +27 -20
  30. package/dist/config.mjs +16 -2
  31. package/dist/config.mjs.map +1 -1
  32. package/dist/ddl.d.mts +2 -1
  33. package/dist/ddl.mjs +3 -0
  34. package/dist/ddl.mjs.map +1 -1
  35. package/dist/diff/engine.d.mts +8 -0
  36. package/dist/diff/engine.mjs +10 -2
  37. package/dist/diff/engine.mjs.map +1 -1
  38. package/dist/index.d.mts +7 -4
  39. package/dist/index.mjs +10 -3
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/logger.d.mts +3 -3
  42. package/dist/logger.mjs.map +1 -1
  43. package/dist/middleware/csrf.mjs +2 -1
  44. package/dist/middleware/csrf.mjs.map +1 -1
  45. package/dist/middleware/rate-limit.mjs +1 -0
  46. package/dist/middleware/rate-limit.mjs.map +1 -1
  47. package/dist/models/baseline.d.mts +1 -0
  48. package/dist/models/baseline.mjs +3 -1
  49. package/dist/models/baseline.mjs.map +1 -1
  50. package/dist/models/build.d.mts +19 -1
  51. package/dist/models/build.mjs +22 -2
  52. package/dist/models/build.mjs.map +1 -1
  53. package/dist/models/comment.d.mts +2 -0
  54. package/dist/models/comment.mjs +3 -1
  55. package/dist/models/comment.mjs.map +1 -1
  56. package/dist/models/label.d.mts +6 -0
  57. package/dist/models/label.mjs +13 -1
  58. package/dist/models/label.mjs.map +1 -1
  59. package/dist/models/member.mjs +2 -1
  60. package/dist/models/member.mjs.map +1 -1
  61. package/dist/models/project.d.mts +1 -0
  62. package/dist/models/project.mjs +3 -2
  63. package/dist/models/project.mjs.map +1 -1
  64. package/dist/models/snapshot.d.mts +1 -0
  65. package/dist/models/snapshot.mjs +2 -1
  66. package/dist/models/snapshot.mjs.map +1 -1
  67. package/dist/models/status-config.mjs +2 -1
  68. package/dist/models/status-config.mjs.map +1 -1
  69. package/dist/models/token.mjs +2 -1
  70. package/dist/models/token.mjs.map +1 -1
  71. package/dist/models/webhook.d.mts +1 -0
  72. package/dist/models/webhook.mjs +2 -1
  73. package/dist/models/webhook.mjs.map +1 -1
  74. package/dist/pages/build-detail.mjs +2 -1
  75. package/dist/pages/build-detail.mjs.map +1 -1
  76. package/dist/pages/build-diff.mjs +1 -0
  77. package/dist/pages/build-diff.mjs.map +1 -1
  78. package/dist/pages/compute-jobs.mjs +2 -0
  79. package/dist/pages/compute-jobs.mjs.map +1 -1
  80. package/dist/pages/label-detail.mjs +227 -0
  81. package/dist/pages/label-detail.mjs.map +1 -0
  82. package/dist/pages/login.mjs +1 -0
  83. package/dist/pages/login.mjs.map +1 -1
  84. package/dist/pages/project-builds.mjs +1 -0
  85. package/dist/pages/project-builds.mjs.map +1 -1
  86. package/dist/pages/project-create.mjs +1 -0
  87. package/dist/pages/project-create.mjs.map +1 -1
  88. package/dist/pages/project-settings.mjs +33 -42
  89. package/dist/pages/project-settings.mjs.map +1 -1
  90. package/dist/pages/projects.mjs +1 -0
  91. package/dist/pages/projects.mjs.map +1 -1
  92. package/dist/pages/root.mjs +1 -0
  93. package/dist/pages/root.mjs.map +1 -1
  94. package/dist/pages/settings-general.mjs +1 -0
  95. package/dist/pages/settings-general.mjs.map +1 -1
  96. package/dist/pages/settings-labels.mjs +1 -0
  97. package/dist/pages/settings-labels.mjs.map +1 -1
  98. package/dist/pages/settings-members.mjs +1 -0
  99. package/dist/pages/settings-members.mjs.map +1 -1
  100. package/dist/pages/settings-status.mjs +1 -0
  101. package/dist/pages/settings-status.mjs.map +1 -1
  102. package/dist/pages/settings-tests.mjs +149 -0
  103. package/dist/pages/settings-tests.mjs.map +1 -0
  104. package/dist/pages/settings-tokens.mjs +1 -0
  105. package/dist/pages/settings-tokens.mjs.map +1 -1
  106. package/dist/pages/settings-webhooks.mjs +1 -0
  107. package/dist/pages/settings-webhooks.mjs.map +1 -1
  108. package/dist/pages/storybook.mjs +59 -0
  109. package/dist/pages/storybook.mjs.map +1 -0
  110. package/dist/retention/purge.d.mts +3 -0
  111. package/dist/retention/purge.mjs +2 -1
  112. package/dist/retention/purge.mjs.map +1 -1
  113. package/dist/routers/admin.mjs +1 -0
  114. package/dist/routers/admin.mjs.map +1 -1
  115. package/dist/routers/assets.mjs +1 -0
  116. package/dist/routers/assets.mjs.map +1 -1
  117. package/dist/routers/auth.mjs +1 -0
  118. package/dist/routers/auth.mjs.map +1 -1
  119. package/dist/routers/builds.handlers.mjs +15 -3
  120. package/dist/routers/builds.handlers.mjs.map +1 -1
  121. package/dist/routers/builds.mjs +5 -2
  122. package/dist/routers/builds.mjs.map +1 -1
  123. package/dist/routers/comments.mjs +1 -0
  124. package/dist/routers/comments.mjs.map +1 -1
  125. package/dist/routers/helpers.mjs +7 -1
  126. package/dist/routers/helpers.mjs.map +1 -1
  127. package/dist/routers/htmx.mjs +2 -0
  128. package/dist/routers/htmx.mjs.map +1 -1
  129. package/dist/routers/labels.mjs +34 -1
  130. package/dist/routers/labels.mjs.map +1 -1
  131. package/dist/routers/media.mjs +1 -0
  132. package/dist/routers/media.mjs.map +1 -1
  133. package/dist/routers/members.mjs +1 -0
  134. package/dist/routers/members.mjs.map +1 -1
  135. package/dist/routers/projects.mjs +1 -0
  136. package/dist/routers/projects.mjs.map +1 -1
  137. package/dist/routers/schemas.mjs +40 -4
  138. package/dist/routers/schemas.mjs.map +1 -1
  139. package/dist/routers/settings.mjs +22 -0
  140. package/dist/routers/settings.mjs.map +1 -1
  141. package/dist/routers/snapshots.mjs +1 -0
  142. package/dist/routers/snapshots.mjs.map +1 -1
  143. package/dist/routers/status-configs.mjs +1 -0
  144. package/dist/routers/status-configs.mjs.map +1 -1
  145. package/dist/routers/storybook.mjs +113 -0
  146. package/dist/routers/storybook.mjs.map +1 -0
  147. package/dist/routers/tokens.mjs +1 -0
  148. package/dist/routers/tokens.mjs.map +1 -1
  149. package/dist/routers/ui.mjs +25 -2
  150. package/dist/routers/ui.mjs.map +1 -1
  151. package/dist/routers/webhooks.mjs +1 -0
  152. package/dist/routers/webhooks.mjs.map +1 -1
  153. package/dist/schema-tables.mjs +166 -0
  154. package/dist/schema-tables.mjs.map +1 -0
  155. package/dist/schema.d.mts +194 -4352
  156. package/dist/schema.mjs +26 -136
  157. package/dist/schema.mjs.map +1 -1
  158. package/dist/store.mjs +2 -0
  159. package/dist/store.mjs.map +1 -1
  160. package/dist/types.d.mts +14 -1
  161. package/dist/types.mjs +8 -0
  162. package/dist/types.mjs.map +1 -1
  163. package/dist/ui/components.mjs +5 -0
  164. package/dist/ui/components.mjs.map +1 -1
  165. package/dist/ui/document.d.mts +1 -0
  166. package/dist/ui/document.mjs +10 -0
  167. package/dist/ui/document.mjs.map +1 -1
  168. package/dist/ui/styles.mjs +1 -0
  169. package/dist/ui/styles.mjs.map +1 -1
  170. package/dist/ui/theme.mjs +2 -0
  171. package/dist/ui/theme.mjs.map +1 -1
  172. package/dist/urls.d.mts +8 -0
  173. package/dist/urls.mjs +7 -0
  174. package/dist/urls.mjs.map +1 -1
  175. package/dist/utils/hash.mjs +3 -0
  176. package/dist/utils/hash.mjs.map +1 -1
  177. package/dist/utils/paths.d.mts +5 -0
  178. package/dist/utils/paths.mjs +5 -0
  179. package/dist/utils/paths.mjs.map +1 -1
  180. package/dist/utils/ulid.d.mts +2 -0
  181. package/dist/utils/ulid.mjs +5 -3
  182. package/dist/utils/ulid.mjs.map +1 -1
  183. package/package.json +76 -67
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["import { swaggerUI } from \"@hono/swagger-ui\";\nimport { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { requestId } from \"hono/request-id\";\nimport type { AuthUser } from \"./adapters/auth.ts\";\nimport type { CaptureQueue } from \"./adapters/capture-queue.ts\";\nimport type { ShelfOptions } from \"./config.ts\";\nimport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nimport { validateConfig, validateUiConfig } from \"./config.ts\";\nimport { createShelfLogger } from \"./logger.ts\";\nimport { csrf, rateLimit } from \"./middleware/index.ts\";\nimport { BuildModel } from \"./models/build.ts\";\nimport { ProjectModel } from \"./models/project.ts\";\nimport { registerAdmin } from \"./routers/admin.ts\";\nimport { registerAssets } from \"./routers/assets.ts\";\nimport { registerAuth } from \"./routers/auth.ts\";\nimport { registerBuilds } from \"./routers/builds.ts\";\nimport { registerLabels } from \"./routers/labels.ts\";\nimport { registerMedia } from \"./routers/media.ts\";\nimport { registerMembers } from \"./routers/members.ts\";\nimport { registerProjects } from \"./routers/projects.ts\";\nimport { registerStatusConfigs } from \"./routers/status-configs.ts\";\nimport { registerTokens } from \"./routers/tokens.ts\";\nimport { registerUiPages } from \"./routers/ui.ts\";\nimport { registerWebhooks } from \"./routers/webhooks.ts\";\nimport { getStore, runWithStore } from \"./store.ts\";\nimport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nimport { InMemoryCaptureQueue } from \"./capture/queue.ts\";\nimport { postStatusesForBuild } from \"./capture/status-fanout.ts\";\n\nexport type ShelfContext = {\n requestId?: string;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n storage: import(\"./adapters/storage.ts\").StorageAdapter;\n config: import(\"./config.ts\").ShelfConfig;\n ui: import(\"./config.ts\").UIConfig;\n logger: ReturnType<typeof createShelfLogger>;\n user: import(\"./adapters/auth.ts\").AuthUser | null;\n authEnabled: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: import(\"./adapters/capture-queue.ts\").CaptureQueue | null;\n gitHosts: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n};\n\nexport type ShelfApp = OpenAPIHono<{ Variables: ShelfContext }>;\n\nfunction buildAdapterSnapshot(options: ShelfOptions): Record<string, AdapterMetadata | GitAdapterMetadata> {\n const snap: Record<string, AdapterMetadata | GitAdapterMetadata> = {};\n if (options.database.metadata) snap[\"database\"] = options.database.metadata;\n if (options.storage.metadata) snap[\"storage\"] = options.storage.metadata;\n if (options.captureRunner?.metadata) snap[\"captureRunner\"] = options.captureRunner.metadata;\n if (options.captureQueue?.metadata) snap[\"captureQueue\"] = options.captureQueue.metadata;\n if (options.auth?.metadata) snap[\"auth\"] = options.auth.metadata;\n for (const p of options.gitHosts ?? []) {\n snap[`git:${p.metadata.kind}`] = p.metadata;\n }\n return snap;\n}\n\nasync function hasApprovedBuildForSha(\n db: import(\"./adapters/database.ts\").DatabaseAdapter,\n projectId: string,\n sha: string,\n excludeBuildId: string,\n): Promise<boolean> {\n const builds = await new BuildModel(db).list(projectId);\n return builds.some((b) => b.gitSha === sha && b.id !== excludeBuildId && b.status === \"approved\");\n}\n\n// eslint-disable-next-line max-statements, complexity\nasync function isAlreadyMerged(opts: {\n providers: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n sha: string;\n branch: string;\n secret: string | undefined;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n projectId: string;\n logger?: import(\"pino\").Logger;\n}): Promise<boolean> {\n if (opts.providers.length === 0) return false;\n const { StatusConfigModel } = await import(\"./models/status-config.ts\");\n const model = new StatusConfigModel(opts.db, opts.secret);\n const rows = await model.list(opts.projectId);\n for (const row of rows) {\n const provider = opts.providers.find((p) => p.metadata.kind === row.provider);\n if (!provider) continue;\n let parsed: unknown;\n try {\n parsed = JSON.parse(row.config);\n provider.metadata.schema.parse(parsed);\n } catch {\n continue;\n }\n let token: string;\n try {\n token = model.decryptToken(row);\n } catch {\n continue;\n }\n const adapter = provider.create({ config: parsed, token, logger: opts.logger });\n if (!adapter.isMerged) continue;\n try {\n // eslint-disable-next-line no-await-in-loop -- short-circuit on first merged status\n const merged = await adapter.isMerged({ sha: opts.sha, branch: opts.branch });\n if (merged) return true;\n } catch {\n // ignore provider errors — do not skip on failure\n }\n }\n return false;\n}\n\nasync function resolveUser(\n c: { req: { raw: Request; header: (name: string) => string | undefined } },\n options: ShelfOptions,\n): Promise<AuthUser | null> {\n if (!options.auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await options.auth.check(c.req.raw);\n}\n\n\nexport function createShelfRouter(options: ShelfOptions): ShelfApp {\n const app = new OpenAPIHono<{ Variables: ShelfContext }>();\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- ShelfConfig lacks index signature\n const rawConfig = options.config ? validateConfig(options.config as unknown as Record<string, unknown>) : {};\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- UIConfig lacks index signature\n const ui = options.ui ? validateUiConfig(options.ui as unknown as Record<string, unknown>) : {};\n const logger = options.logger ?? createShelfLogger();\n const authEnabled = options.auth !== undefined;\n\n const gitHosts = options.gitHosts ?? [];\n\n // Adapter introspection — auto-populate config.adapters if not supplied\n const config: import(\"./config.ts\").ShelfConfig = rawConfig.adapters\n ? rawConfig\n : {\n ...rawConfig,\n adapters: buildAdapterSnapshot(options),\n };\n\n let queue: CaptureQueue | null = null;\n let enqueueCapture: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n if (options.captureRunner) {\n if (!config.scratchDir) {\n throw new Error(\"captureRunner is enabled but ShelfConfig.scratchDir is not set\");\n }\n const jobOptions: CaptureJobOptions = {\n db: options.database,\n storage: options.storage,\n runner: options.captureRunner,\n scratchDir: config.scratchDir,\n viewports: config.viewports,\n logger,\n };\n const captureQueue =\n options.captureQueue ??\n new InMemoryCaptureQueue({\n concurrency: config.captureConcurrency ?? 2,\n logger,\n runJob: async (job): Promise<void> => {\n const builds = new BuildModel(options.database);\n const build = await builds.get(job.buildId);\n if (!build) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const project = await new ProjectModel(options.database).get(build.projectId);\n if (!project) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const pendingUrl = `/projects/${project.slug}/builds/${job.buildId}`;\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"pending\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post pending status\");\n });\n // Skip capture if already merged (PR closed) or locally deduped\n if (!build.isDefault) {\n const merged = await isAlreadyMerged({\n providers: gitHosts,\n sha: build.gitSha,\n branch: build.gitBranch,\n secret: config.secret,\n db: options.database,\n projectId: project.id,\n logger,\n }).catch(() => false);\n if (merged) {\n logger.info({ buildId: build.id, sha: build.gitSha, branch: build.gitBranch }, \"skipping capture — already merged\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // intentionally empty — fire-and-forget\n return;\n }\n }\n // Local dedupe: if another approved build for same sha exists, skip render\n const dup = await hasApprovedBuildForSha(options.database, project.id, build.gitSha, build.id);\n if (dup) {\n logger.info({ buildId: build.id, sha: build.gitSha }, \"skipping capture — duplicate sha already approved\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // intentionally empty — fire-and-forget\n return;\n }\n try {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n const updated = await builds.get(job.buildId);\n if (updated) {\n const terminal =\n updated.status === \"approved\"\n ? \"success\"\n : updated.status === \"rejected\" || updated.status === \"failed\"\n ? \"failure\"\n : null;\n if (terminal) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: updated.gitSha,\n status: terminal,\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post terminal status\");\n });\n }\n }\n } catch (error: unknown) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"failure\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {\n // ignore: status post failure already logged\n });\n throw error;\n }\n },\n });\n queue = captureQueue;\n enqueueCapture = async (buildId: string, reqId?: string): Promise<void> => {\n await captureQueue.enqueue({ buildId, reqId });\n };\n }\n\n app.use(\"*\", requestId());\n\n // Structured request logging. Uses a Hono-native middleware rather than\n // pino-http, which expects a Node server response (`res.on`) incompatible\n // with Hono's Web `Request`/`Response` model.\n app.use(\"*\", async (c, next) => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info({ reqId: id, method: c.req.method, url: c.req.path }, \"request start\");\n await next();\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: c.req.path,\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n },\n \"request end\",\n );\n });\n\n app.use(\"/api/v1/*\", rateLimit({ windowMs: 60_000, max: 100 }));\n app.use(\"/api/v1/tokens/*\", rateLimit({ windowMs: 60_000, max: 10 }));\n app.use(\"/api/v1/webhooks/*\", rateLimit({ windowMs: 60_000, max: 20 }));\n app.use(\"/projects/:slug/settings/*\", csrf());\n\n app.use(\"*\", async (c, next) => {\n const user = await resolveUser(c, options);\n return runWithStore(\n {\n db: options.database,\n storage: options.storage,\n config,\n ui,\n logger,\n user,\n authEnabled,\n enqueueCapture,\n captureQueue: queue,\n gitHosts,\n },\n async () => {\n await next();\n },\n );\n });\n\n // Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n // requests are redirected to the login page. API and auth routes are handled\n // by their own routers (401/403 vs the login flow).\n // eslint-disable-next-line require-await -- async is required to match Hono's middleware signature\n app.use(\"*\", async (c, next) => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const path = c.req.path;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\")\n ) {\n return next();\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n });\n\n registerProjects(app);\n registerBuilds(app);\n registerLabels(app);\n registerMedia(app);\n registerMembers(app);\n registerTokens(app);\n registerWebhooks(app);\n registerStatusConfigs(app);\n registerAdmin(app);\n if (options.auth) {\n registerAuth(app, options.auth);\n }\n registerAssets(app);\n registerUiPages(app);\n\n app.doc(\"/api/v1/openapi.json\", {\n openapi: \"3.0.0\",\n info: {\n title: \"StoryShelf API\",\n description:\n \"REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.\",\n version: \"1.0.0\",\n },\n });\n app.get(\"/api/v1/docs\", swaggerUI({ url: \"/api/v1/openapi.json\" }));\n\n return app;\n}\n\nexport type { ShelfOptions, ShelfConfig, UIConfig, BrandTheme } from \"./config.ts\";\nexport type { DatabaseAdapter, ListOptions } from \"./adapters/database.ts\";\nexport type { StorageAdapter } from \"./adapters/storage.ts\";\nexport type {\n CaptureRunner,\n JobStatus,\n RenderedSnapshot,\n RenderResult,\n RenderFailure,\n} from \"./adapters/capture-runner.ts\";\nexport type { CaptureQueue, CaptureJob, QueueEntry } from \"./adapters/capture-queue.ts\";\nexport type { AuthAdapter, AuthUser, AuthCallback } from \"./adapters/auth.ts\";\nexport type { GitHostProvider, GitHostAdapter, CheckStatus } from \"./adapters/git-host/index.ts\";\nexport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nexport { describeStatus, buildCommentMarkdown, commentMarker } from \"./adapters/git-host/helpers.ts\";\nexport type { DiffOptions, DiffResult } from \"./diff/options.ts\";\nexport type { Viewport, StoryEntry, StorySourceAdapter } from \"./capture/adapter.ts\";\nexport { createShelfLogger, type LoggerOptions, type PinoTransport } from \"./logger.ts\";\nexport { diffImages } from \"./diff/engine.ts\";\nexport type { RenderedContent } from \"./ui/document.tsx\";\nexport { StorybookAdapter } from \"./capture/storybook.ts\";\nexport { persistCapture, type CaptureContext } from \"./capture/pipeline.ts\";\nexport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nexport { DEFAULT_VIEWPORTS } from \"./capture/adapter.ts\";\nexport { InMemoryCaptureQueue, type InMemoryCaptureQueueOptions } from \"./capture/queue.ts\";\nexport { Retention } from \"./retention/purge.ts\";\nexport { createUrlBuilder, type UrlBuilder } from \"./urls.ts\";\nexport { ulid, slugify } from \"./utils/ulid.ts\";\nexport {\n baselinePath,\n diffPath,\n screenshotPath,\n storybookDir,\n storybookZipPath,\n} from \"./utils/paths.ts\";\nexport * from \"./schema.ts\";\nexport * from \"./types.ts\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.tsx"],"sourcesContent":["/**\n * StoryShelf core: compose database, storage, capture, auth, and git-host\n * adapters into a complete self-hosted visual-testing Hono server.\n */\nimport { swaggerUI } from \"@hono/swagger-ui\";\nimport { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { requestId } from \"hono/request-id\";\nimport type { AuthUser } from \"./adapters/auth.ts\";\nimport type { CaptureQueue } from \"./adapters/capture-queue.ts\";\nimport type { ShelfOptions } from \"./config.ts\";\nimport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nimport { validateConfig, validateUiConfig } from \"./config.ts\";\nimport { createShelfLogger } from \"./logger.ts\";\nimport { csrf, rateLimit } from \"./middleware/index.ts\";\nimport { BuildModel } from \"./models/build.ts\";\nimport { ProjectModel } from \"./models/project.ts\";\nimport { registerAdmin } from \"./routers/admin.ts\";\nimport { registerAssets } from \"./routers/assets.ts\";\nimport { registerAuth } from \"./routers/auth.ts\";\nimport { registerBuilds } from \"./routers/builds.ts\";\nimport { registerLabels } from \"./routers/labels.ts\";\nimport { registerMedia } from \"./routers/media.ts\";\nimport { registerMembers } from \"./routers/members.ts\";\nimport { registerProjects } from \"./routers/projects.ts\";\nimport { registerStatusConfigs } from \"./routers/status-configs.ts\";\nimport { registerStorybook } from \"./routers/storybook.ts\";\nimport { registerTokens } from \"./routers/tokens.ts\";\nimport { registerUiPages } from \"./routers/ui.ts\";\nimport { registerWebhooks } from \"./routers/webhooks.ts\";\nimport { getStore, runWithStore } from \"./store.ts\";\nimport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nimport { InMemoryCaptureQueue } from \"./capture/queue.ts\";\nimport { postStatusesForBuild } from \"./capture/status-fanout.ts\";\n\n/** Per-request shelf context (adapters, config, user, capture queue). */\nexport interface ShelfContext {\n requestId?: string;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n storage: import(\"./adapters/storage.ts\").StorageAdapter;\n config: import(\"./config.ts\").ShelfConfig;\n ui: import(\"./config.ts\").UIConfig;\n logger: ReturnType<typeof createShelfLogger>;\n user: import(\"./adapters/auth.ts\").AuthUser | null;\n authEnabled: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: import(\"./adapters/capture-queue.ts\").CaptureQueue | null;\n gitHosts: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n}\n\n/** Hono application type carrying the shelf context variables. */\nexport type ShelfApp = OpenAPIHono<{ Variables: ShelfContext }>;\n\nfunction buildAdapterSnapshot(options: ShelfOptions): Record<string, AdapterMetadata | GitAdapterMetadata> {\n const snap: Record<string, AdapterMetadata | GitAdapterMetadata> = {};\n if (options.database.metadata) {snap[\"database\"] = options.database.metadata;}\n if (options.storage.metadata) {snap[\"storage\"] = options.storage.metadata;}\n if (options.captureRunner?.metadata) {snap[\"captureRunner\"] = options.captureRunner.metadata;}\n if (options.captureQueue?.metadata) {snap[\"captureQueue\"] = options.captureQueue.metadata;}\n if (options.auth?.metadata) {snap[\"auth\"] = options.auth.metadata;}\n for (const p of options.gitHosts ?? []) {\n snap[`git:${p.metadata.kind}`] = p.metadata;\n }\n return snap;\n}\n\nasync function hasApprovedBuildForSha(\n db: import(\"./adapters/database.ts\").DatabaseAdapter,\n projectId: string,\n sha: string,\n excludeBuildId: string,\n): Promise<boolean> {\n const builds = await new BuildModel(db).list(projectId);\n return builds.some((b) => b.gitSha === sha && b.id !== excludeBuildId && b.status === \"approved\");\n}\n\n// eslint-disable-next-line max-statements, complexity\nasync function isAlreadyMerged(opts: {\n providers: import(\"./adapters/git-host/index.ts\").GitHostProvider[];\n sha: string;\n branch: string;\n secret: string | undefined;\n db: import(\"./adapters/database.ts\").DatabaseAdapter;\n projectId: string;\n logger?: import(\"./logger.ts\").Logger;\n}): Promise<boolean> {\n if (opts.providers.length === 0) {return false;}\n const { StatusConfigModel } = await import(\"./models/status-config.ts\");\n const model = new StatusConfigModel(opts.db, opts.secret);\n const rows = await model.list(opts.projectId);\n for (const row of rows) {\n const provider = opts.providers.find((p) => p.metadata.kind === row.provider);\n if (!provider) {continue;}\n let parsed: unknown;\n try {\n parsed = JSON.parse(row.config);\n provider.metadata.schema.parse(parsed);\n } catch {\n continue;\n }\n let token: string;\n try {\n token = model.decryptToken(row);\n } catch {\n continue;\n }\n const adapter = provider.create({ config: parsed, token, logger: opts.logger });\n if (!adapter.isMerged) {continue;}\n try {\n // eslint-disable-next-line no-await-in-loop -- short-circuit on first merged status\n const merged = await adapter.isMerged({ sha: opts.sha, branch: opts.branch });\n if (merged) {return true;}\n } catch {\n // Ignore provider errors — do not skip on failure\n }\n }\n return false;\n}\n\nasync function resolveUser(\n c: { req: { raw: Request; header: (name: string) => string | undefined } },\n options: ShelfOptions,\n): Promise<AuthUser | null> {\n if (!options.auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await options.auth.check(c.req.raw);\n}\n\n\n/** Create the StoryShelf Hono router with all API routes and HTML pages. */\nexport function createShelfRouter(options: ShelfOptions): ShelfApp {\n const app = new OpenAPIHono<{ Variables: ShelfContext }>();\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- ShelfConfig lacks index signature\n const rawConfig = options.config ? validateConfig(options.config as unknown as Record<string, unknown>) : {};\n // eslint-disable-next-line typescript/no-unnecessary-type-assertion -- UIConfig lacks index signature\n const ui = options.ui ? validateUiConfig(options.ui as unknown as Record<string, unknown>) : {};\n const logger = options.logger ?? createShelfLogger();\n const authEnabled = options.auth !== undefined;\n\n const gitHosts = options.gitHosts ?? [];\n\n // Adapter introspection — auto-populate config.adapters if not supplied\n const config: import(\"./config.ts\").ShelfConfig = rawConfig.adapters\n ? rawConfig\n : {\n ...rawConfig,\n adapters: buildAdapterSnapshot(options),\n };\n\n let queue: CaptureQueue | null = null;\n let enqueueCapture: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n if (options.captureRunner) {\n if (!config.scratchDir) {\n throw new Error(\"captureRunner is enabled but ShelfConfig.scratchDir is not set\");\n }\n const jobOptions: CaptureJobOptions = {\n db: options.database,\n storage: options.storage,\n runner: options.captureRunner,\n scratchDir: config.scratchDir,\n viewports: config.viewports,\n logger,\n };\n const captureQueue =\n options.captureQueue ??\n new InMemoryCaptureQueue({\n concurrency: config.captureConcurrency ?? 2,\n logger,\n runJob: async (job): Promise<void> => {\n const builds = new BuildModel(options.database);\n const build = await builds.get(job.buildId);\n if (!build) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const project = await new ProjectModel(options.database).get(build.projectId);\n if (!project) {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n return;\n }\n const pendingUrl = `/projects/${project.slug}/builds/${job.buildId}`;\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"pending\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post pending status\");\n });\n // Skip capture if already merged (PR closed) or locally deduped\n if (!build.isDefault) {\n const merged = await isAlreadyMerged({\n providers: gitHosts,\n sha: build.gitSha,\n branch: build.gitBranch,\n secret: config.secret,\n db: options.database,\n projectId: project.id,\n logger,\n }).catch(() => false);\n if (merged) {\n logger.info({ buildId: build.id, sha: build.gitSha, branch: build.gitBranch }, \"skipping capture — already merged\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // Intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // Intentionally empty — fire-and-forget\n return;\n }\n }\n // Local dedupe: if another approved build for same sha exists, skip render\n const dup = await hasApprovedBuildForSha(options.database, project.id, build.gitSha, build.id);\n if (dup) {\n logger.info({ buildId: build.id, sha: build.gitSha }, \"skipping capture — duplicate sha already approved\");\n await builds.setStatus(build.id, \"approved\").catch(() => {}); // Intentionally empty — fire-and-forget\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"success\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {}); // Intentionally empty — fire-and-forget\n return;\n }\n try {\n await executeCaptureJob({ buildId: job.buildId, reqId: job.reqId }, jobOptions);\n const updated = await builds.get(job.buildId);\n if (updated) {\n const terminal =\n updated.status === \"approved\"\n ? \"success\"\n : (updated.status === \"rejected\" || updated.status === \"failed\"\n ? \"failure\"\n : null);\n if (terminal) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: updated.gitSha,\n status: terminal,\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch((error: unknown) => {\n logger.error({ err: error }, \"failed to post terminal status\");\n });\n }\n }\n } catch (error: unknown) {\n await postStatusesForBuild({\n db: options.database,\n project,\n sha: build.gitSha,\n status: \"failure\",\n url: pendingUrl,\n providers: gitHosts,\n secret: config.secret,\n logger,\n }).catch(() => {\n // Ignore: status post failure already logged\n });\n throw error;\n }\n },\n });\n queue = captureQueue;\n enqueueCapture = async (buildId: string, reqId?: string): Promise<void> => {\n await captureQueue.enqueue({ buildId, reqId });\n };\n }\n\n app.use(\"*\", requestId());\n\n // Structured request logging. Uses a Hono-native middleware rather than\n // Pino-http, which expects a Node server response (`res.on`) incompatible\n // With Hono's Web `Request`/`Response` model.\n app.use(\"*\", async (c, next) => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info({ reqId: id, method: c.req.method, url: c.req.path }, \"request start\");\n await next();\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: c.req.path,\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n },\n \"request end\",\n );\n });\n\n app.use(\"/api/v1/*\", rateLimit({ windowMs: 60_000, max: 100 }));\n app.use(\"/api/v1/tokens/*\", rateLimit({ windowMs: 60_000, max: 10 }));\n app.use(\"/api/v1/webhooks/*\", rateLimit({ windowMs: 60_000, max: 20 }));\n app.use(\"/projects/:slug/settings/*\", csrf());\n\n app.use(\"*\", async (c, next) => {\n const user = await resolveUser(c, options);\n return runWithStore(\n {\n db: options.database,\n storage: options.storage,\n config,\n ui,\n logger,\n user,\n authEnabled,\n enqueueCapture,\n captureQueue: queue,\n gitHosts,\n },\n async () => {\n await next();\n },\n );\n });\n\n // Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n // Requests are redirected to the login page. API and auth routes are handled\n // By their own routers (401/403 vs the login flow).\n // eslint-disable-next-line require-await -- async is required to match Hono's middleware signature\n app.use(\"*\", async (c, next) => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const {path} = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n // Published Storybook routes enforce their own auth inside the handler\n // (public builds are viewable without a session, ADR 0011).\n path.startsWith(\"/projects/\") && path.includes(\"/storybook\")\n ) {\n return next();\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n });\n\n registerProjects(app);\n registerBuilds(app);\n registerLabels(app);\n registerMedia(app);\n registerMembers(app);\n registerTokens(app);\n registerWebhooks(app);\n registerStatusConfigs(app);\n registerAdmin(app);\n if (options.auth) {\n registerAuth(app, options.auth);\n }\n registerAssets(app);\n registerStorybook(app);\n registerUiPages(app);\n\n app.doc(\"/api/v1/openapi.json\", {\n openapi: \"3.0.0\",\n info: {\n title: \"StoryShelf API\",\n description:\n \"REST API for the StoryShelf visual testing platform. JSON endpoints live under /api/v1; HTML pages are served at /.\",\n version: \"1.0.0\",\n },\n });\n app.get(\"/api/v1/docs\", swaggerUI({ url: \"/api/v1/openapi.json\" }));\n\n return app;\n}\n\nexport type { ShelfOptions, ShelfConfig, UIConfig, BrandTheme } from \"./config.ts\";\nexport type { DatabaseAdapter, ListOptions } from \"./adapters/database.ts\";\nexport type { StorageAdapter } from \"./adapters/storage.ts\";\nexport type {\n CaptureRunner,\n JobStatus,\n RenderedSnapshot,\n RenderResult,\n RenderFailure,\n} from \"./adapters/capture-runner.ts\";\nexport type { CaptureQueue, CaptureJob, QueueEntry } from \"./adapters/capture-queue.ts\";\nexport type { AuthAdapter, AuthUser, AuthCallback } from \"./adapters/auth.ts\";\nexport type { GitHostProvider, GitHostAdapter, CheckStatus } from \"./adapters/git-host/index.ts\";\nexport type { AdapterMetadata, GitAdapterMetadata } from \"./adapters/metadata.ts\";\nexport { describeStatus, buildCommentMarkdown, commentMarker } from \"./adapters/git-host/helpers.ts\";\nexport type { DiffOptions, DiffResult } from \"./diff/options.ts\";\nexport type { Viewport, StoryEntry, StorySourceAdapter } from \"./capture/adapter.ts\";\nexport { createShelfLogger, type LoggerOptions, type Logger, type PinoTransport } from \"./logger.ts\";\nexport { diffImages } from \"./diff/engine.ts\";\nexport type { RenderedContent } from \"./ui/document.tsx\";\nexport { StorybookAdapter } from \"./capture/storybook.ts\";\nexport { persistCapture, type CaptureContext } from \"./capture/pipeline.ts\";\nexport { executeCaptureJob, type CaptureJobOptions } from \"./capture/orchestrator.ts\";\nexport { DEFAULT_VIEWPORTS } from \"./capture/adapter.ts\";\nexport { InMemoryCaptureQueue, type InMemoryCaptureQueueOptions } from \"./capture/queue.ts\";\nexport { Retention } from \"./retention/purge.ts\";\nexport { createUrlBuilder, type UrlBuilder } from \"./urls.ts\";\nexport { ulid, slugify } from \"./utils/ulid.ts\";\nexport {\n baselinePath,\n diffPath,\n screenshotPath,\n storybookDir,\n storybookZipPath,\n} from \"./utils/paths.ts\";\nexport * from \"./schema.ts\";\nexport * from \"./types.ts\";\n"],"mappings":"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"}
package/dist/logger.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { Logger } from "pino";
3
+ import { Logger as Logger$1 } from "pino";
4
4
  //#region src/logger.d.ts
5
5
  /** A pino worker transport to attach to the logger output. */
6
6
  interface PinoTransport {
@@ -33,7 +33,7 @@ interface LoggerOptions {
33
33
  * Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —
34
34
  * they are sinks for this logger, not standalone loggers.
35
35
  */
36
- declare function createShelfLogger(options?: LoggerOptions): Logger;
36
+ declare function createShelfLogger(options?: LoggerOptions): Logger$1;
37
37
  //#endregion
38
- export { LoggerOptions, PinoTransport, createShelfLogger };
38
+ export { type Logger$1 as Logger, LoggerOptions, PinoTransport, createShelfLogger };
39
39
  //# sourceMappingURL=logger.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["import pino, { type Logger } from \"pino\";\n\n/** A pino worker transport to attach to the logger output. */\nexport interface PinoTransport {\n /** Package name or absolute path of the transport module. */\n target: string;\n /** Options passed to the transport worker. */\n options?: Record<string, unknown>;\n}\n\n/** Configuration for constructing the shelf logger. */\nexport interface LoggerOptions {\n /** Minimum level to emit. Defaults to `\"info\"`. */\n level?: string;\n /** Extra pino worker transports appended to the default stdout sink. */\n transports?: PinoTransport[];\n /** Deployment environment recorded in the `env` base field. Defaults to `NODE_ENV`. */\n env?: string;\n}\n\n/**\n * Create the structured JSON logger used across StoryShelf.\n *\n * Logging conventions:\n * - Always log structured objects, never interpolate into the message:\n * `logger.info({ projectId, buildId }, \"build started\")`.\n * - Attach errors as an `err` child field rather than stringifying into the message:\n * `logger.error({ err }, \"capture failed\")`.\n * - Derive scoped child loggers for background work:\n * `const captureLogger = logger.child({ buildId })`.\n *\n * The default sink is stdout. Hosted observability platforms (Sentry, PostHog,\n * Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —\n * they are sinks for this logger, not standalone loggers.\n */\nexport function createShelfLogger(options: LoggerOptions = {}): Logger {\n const targets = [{ target: \"pino/file\", options: { destination: 1 } }, ...(options.transports ?? [])];\n const transport = pino.transport({ targets });\n return pino(\n {\n level: options.level ?? \"info\",\n base: { env: options.env ?? process.env[\"NODE_ENV\"] },\n },\n transport,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,kBAAkB,UAAyB,CAAC,GAAW;CACrE,MAAM,UAAU,CAAC;EAAE,QAAQ;EAAa,SAAS,EAAE,aAAa,EAAE;CAAE,GAAG,GAAI,QAAQ,cAAc,CAAC,CAAE;CACpG,MAAM,YAAY,KAAK,UAAU,EAAE,QAAQ,CAAC;CAC5C,OAAO,KACL;EACE,OAAO,QAAQ,SAAS;EACxB,MAAM,EAAE,KAAK,QAAQ,OAAO,QAAQ,IAAI,YAAY;CACtD,GACA,SACF;AACF"}
1
+ {"version":3,"file":"logger.mjs","names":[],"sources":["../src/logger.ts"],"sourcesContent":["import pino from \"pino\";\nimport type { Logger } from \"pino\";\n\n/** Pino logger type shared across StoryShelf. */\nexport type { Logger };\n\n/** A pino worker transport to attach to the logger output. */\nexport interface PinoTransport {\n /** Package name or absolute path of the transport module. */\n target: string;\n /** Options passed to the transport worker. */\n options?: Record<string, unknown>;\n}\n\n/** Configuration for constructing the shelf logger. */\nexport interface LoggerOptions {\n /** Minimum level to emit. Defaults to `\"info\"`. */\n level?: string;\n /** Extra pino worker transports appended to the default stdout sink. */\n transports?: PinoTransport[];\n /** Deployment environment recorded in the `env` base field. Defaults to `NODE_ENV`. */\n env?: string;\n}\n\n/**\n * Create the structured JSON logger used across StoryShelf.\n *\n * Logging conventions:\n * - Always log structured objects, never interpolate into the message:\n * `logger.info({ projectId, buildId }, \"build started\")`.\n * - Attach errors as an `err` child field rather than stringifying into the message:\n * `logger.error({ err }, \"capture failed\")`.\n * - Derive scoped child loggers for background work:\n * `const captureLogger = logger.child({ buildId })`.\n *\n * The default sink is stdout. Hosted observability platforms (Sentry, PostHog,\n * Datadog, GCP, OTEL collector, etc.) are added as pino worker `transports` —\n * they are sinks for this logger, not standalone loggers.\n */\nexport function createShelfLogger(options: LoggerOptions = {}): Logger {\n const targets = [\n { target: \"pino/file\", options: { destination: 1 } },\n ...(options.transports ?? []),\n ];\n const transport = pino.transport({ targets });\n return pino(\n {\n level: options.level ?? \"info\",\n base: { env: options.env ?? process.env[\"NODE_ENV\"] },\n },\n transport,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuCA,SAAgB,kBAAkB,UAAyB,CAAC,GAAW;CACrE,MAAM,UAAU,CACd;EAAE,QAAQ;EAAa,SAAS,EAAE,aAAa,EAAE;CAAE,GACnD,GAAI,QAAQ,cAAc,CAAC,CAC7B;CACA,MAAM,YAAY,KAAK,UAAU,EAAE,QAAQ,CAAC;CAC5C,OAAO,KACL;EACE,OAAO,QAAQ,SAAS;EACxB,MAAM,EAAE,KAAK,QAAQ,OAAO,QAAQ,IAAI,YAAY;CACtD,GACA,SACF;AACF"}
@@ -18,9 +18,10 @@ function verifyToken(token, sessionId) {
18
18
  const age = Date.now() - timestamp;
19
19
  return age > 0 && age < 864e5;
20
20
  }
21
+ /** Hono middleware issuing CSRF tokens on safe methods and verifying them on writes. */
21
22
  function csrf() {
22
23
  return async (c, next) => {
23
- const method = c.req.method;
24
+ const { method } = c.req;
24
25
  if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
25
26
  const token = generateToken(c.req.header("session-id") ?? "default");
26
27
  c.header("X-CSRF-Token", token);
@@ -1 +1 @@
1
- {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["import { createHash, randomBytes } from \"node:crypto\";\nimport type { Context, Next } from \"hono\";\n\nconst CSRF_SECRET = process.env[\"CSRF_SECRET\"] ?? randomBytes(32).toString(\"hex\");\n\nfunction generateToken(sessionId: string): string {\n const timestamp = Date.now().toString(36);\n const payload = `${sessionId}:${timestamp}`;\n const signature = createHash(\"sha256\").update(`${CSRF_SECRET}:${payload}`).digest(\"hex\").slice(0, 16);\n return `${payload}:${signature}`;\n}\n\nfunction verifyToken(token: string, sessionId: string): boolean {\n const parts = token.split(\":\");\n if (parts.length !== 3) {\n return false;\n }\n const [payloadTimestamp, signature] = parts.slice(1);\n if (!payloadTimestamp || !signature) {\n return false;\n }\n const expectedSignature = createHash(\"sha256\").update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`).digest(\"hex\").slice(0, 16);\n if (signature !== expectedSignature) {\n return false;\n }\n const timestamp = Number.parseInt(payloadTimestamp, 36);\n const age = Date.now() - timestamp;\n return age > 0 && age < 24 * 60 * 60 * 1000;\n}\n\nexport function csrf() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const method = c.req.method;\n if (method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\") {\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = generateToken(sessionId);\n c.header(\"X-CSRF-Token\", token);\n await next();\n return;\n }\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = c.req.header(\"x-csrf-token\") ?? c.req.query(\"csrf_token\");\n if (!token || !verifyToken(token, sessionId)) {\n return c.json({ error: \"Invalid CSRF token\" }, 403);\n }\n await next();\n };\n}\n\nexport function getCsrfToken(sessionId: string): string {\n return generateToken(sessionId);\n}\n\n"],"mappings":";;;;;AAGA,MAAM,cAAc,QAAQ,IAAI,kBAAkB,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;AAEhF,SAAS,cAAc,WAA2B;CAEhD,MAAM,UAAU,GAAG,UAAU,GADX,KAAK,IAAI,CAAC,CAAC,SAAS,EACE;CAExC,OAAO,GAAG,QAAQ,GADA,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,SAAS,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EACrE;AAC/B;AAEA,SAAS,YAAY,OAAe,WAA4B;CAC9D,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GACnB,OAAO;CAET,MAAM,CAAC,kBAAkB,aAAa,MAAM,MAAM,CAAC;CACnD,IAAI,CAAC,oBAAoB,CAAC,WACxB,OAAO;CAGT,IAAI,cADsB,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,UAAU,GAAG,kBAAkB,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EAC9F,GAChC,OAAO;CAET,MAAM,YAAY,OAAO,SAAS,kBAAkB,EAAE;CACtD,MAAM,MAAM,KAAK,IAAI,IAAI;CACzB,OAAO,MAAM,KAAK,MAAM;AAC1B;AAEA,SAAgB,OAAO;CAErB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,SAAS,EAAE,IAAI;EACrB,IAAI,WAAW,SAAS,WAAW,UAAU,WAAW,WAAW;GAEjE,MAAM,QAAQ,cADI,EAAE,IAAI,OAAO,YAAY,KAAK,SACX;GACrC,EAAE,OAAO,gBAAgB,KAAK;GAC9B,MAAM,KAAK;GACX;EACF;EACA,MAAM,YAAY,EAAE,IAAI,OAAO,YAAY,KAAK;EAChD,MAAM,QAAQ,EAAE,IAAI,OAAO,cAAc,KAAK,EAAE,IAAI,MAAM,YAAY;EACtE,IAAI,CAAC,SAAS,CAAC,YAAY,OAAO,SAAS,GACzC,OAAO,EAAE,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;EAEpD,MAAM,KAAK;CACb;AACF"}
1
+ {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/middleware/csrf.ts"],"sourcesContent":["import { createHash, randomBytes } from \"node:crypto\";\nimport type { Context, Next } from \"hono\";\n\nconst CSRF_SECRET = process.env[\"CSRF_SECRET\"] ?? randomBytes(32).toString(\"hex\");\n\nfunction generateToken(sessionId: string): string {\n const timestamp = Date.now().toString(36);\n const payload = `${sessionId}:${timestamp}`;\n const signature = createHash(\"sha256\").update(`${CSRF_SECRET}:${payload}`).digest(\"hex\").slice(0, 16);\n return `${payload}:${signature}`;\n}\n\nfunction verifyToken(token: string, sessionId: string): boolean {\n const parts = token.split(\":\");\n if (parts.length !== 3) {\n return false;\n }\n const [payloadTimestamp, signature] = parts.slice(1);\n if (!payloadTimestamp || !signature) {\n return false;\n }\n const expectedSignature = createHash(\"sha256\").update(`${CSRF_SECRET}:${sessionId}:${payloadTimestamp}`).digest(\"hex\").slice(0, 16);\n if (signature !== expectedSignature) {\n return false;\n }\n const timestamp = Number.parseInt(payloadTimestamp, 36);\n const age = Date.now() - timestamp;\n return age > 0 && age < 24 * 60 * 60 * 1000;\n}\n\n/** Hono middleware issuing CSRF tokens on safe methods and verifying them on writes. */\nexport function csrf() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const {method} = c.req;\n if (method === \"GET\" || method === \"HEAD\" || method === \"OPTIONS\") {\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = generateToken(sessionId);\n c.header(\"X-CSRF-Token\", token);\n await next();\n return;\n }\n const sessionId = c.req.header(\"session-id\") ?? \"default\";\n const token = c.req.header(\"x-csrf-token\") ?? c.req.query(\"csrf_token\");\n if (!token || !verifyToken(token, sessionId)) {\n return c.json({ error: \"Invalid CSRF token\" }, 403);\n }\n await next();\n };\n}\n\n/** Generate a CSRF token for the given session. */\nexport function getCsrfToken(sessionId: string): string {\n return generateToken(sessionId);\n}\n\n"],"mappings":";;;;;AAGA,MAAM,cAAc,QAAQ,IAAI,kBAAkB,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;AAEhF,SAAS,cAAc,WAA2B;CAEhD,MAAM,UAAU,GAAG,UAAU,GADX,KAAK,IAAI,CAAC,CAAC,SAAS,EACE;CAExC,OAAO,GAAG,QAAQ,GADA,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,SAAS,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EACrE;AAC/B;AAEA,SAAS,YAAY,OAAe,WAA4B;CAC9D,MAAM,QAAQ,MAAM,MAAM,GAAG;CAC7B,IAAI,MAAM,WAAW,GACnB,OAAO;CAET,MAAM,CAAC,kBAAkB,aAAa,MAAM,MAAM,CAAC;CACnD,IAAI,CAAC,oBAAoB,CAAC,WACxB,OAAO;CAGT,IAAI,cADsB,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,YAAY,GAAG,UAAU,GAAG,kBAAkB,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,EAC9F,GAChC,OAAO;CAET,MAAM,YAAY,OAAO,SAAS,kBAAkB,EAAE;CACtD,MAAM,MAAM,KAAK,IAAI,IAAI;CACzB,OAAO,MAAM,KAAK,MAAM;AAC1B;;AAGA,SAAgB,OAAO;CAErB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAC,WAAU,EAAE;EACnB,IAAI,WAAW,SAAS,WAAW,UAAU,WAAW,WAAW;GAEjE,MAAM,QAAQ,cADI,EAAE,IAAI,OAAO,YAAY,KAAK,SACX;GACrC,EAAE,OAAO,gBAAgB,KAAK;GAC9B,MAAM,KAAK;GACX;EACF;EACA,MAAM,YAAY,EAAE,IAAI,OAAO,YAAY,KAAK;EAChD,MAAM,QAAQ,EAAE,IAAI,OAAO,cAAc,KAAK,EAAE,IAAI,MAAM,YAAY;EACtE,IAAI,CAAC,SAAS,CAAC,YAAY,OAAO,SAAS,GACzC,OAAO,EAAE,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;EAEpD,MAAM,KAAK;CACb;AACF"}
@@ -8,6 +8,7 @@ function cleanup() {
8
8
  for (const [key, entry] of stores) if (now > entry.resetAt) stores.delete(key);
9
9
  }
10
10
  setInterval(cleanup, 6e4).unref();
11
+ /** Hono middleware limiting requests per key within a sliding window. */
11
12
  function rateLimit(options) {
12
13
  const { windowMs, max, keyGenerator } = options;
13
14
  return async (c, next) => {
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\ninterface RateLimitOptions {\n windowMs: number;\n max: number;\n keyGenerator?: (c: Context) => string;\n}\n\nconst stores = new Map<string, RateLimitEntry>();\n\nfunction cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of stores) {\n if (now > entry.resetAt) {\n stores.delete(key);\n }\n }\n}\n\nsetInterval(cleanup, 60_000).unref();\n\nexport function rateLimit(options: RateLimitOptions) {\n const { windowMs, max, keyGenerator } = options;\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const key = keyGenerator ? keyGenerator(c) : (c.req.header(\"x-forwarded-for\") ?? \"anonymous\");\n const now = Date.now();\n let entry = stores.get(key);\n if (!entry || now > entry.resetAt) {\n entry = { count: 0, resetAt: now + windowMs };\n stores.set(key, entry);\n }\n entry.count += 1;\n if (entry.count > max) {\n const retryAfter = Math.ceil((entry.resetAt - now) / 1000);\n c.header(\"Retry-After\", String(retryAfter));\n return c.json({ error: \"Too many requests\" }, 429);\n }\n c.header(\"X-RateLimit-Limit\", String(max));\n c.header(\"X-RateLimit-Remaining\", String(Math.max(0, max - entry.count)));\n await next();\n };\n}\n"],"mappings":";;;;AAaA,MAAM,yBAAS,IAAI,IAA4B;AAE/C,SAAS,UAAgB;CACvB,MAAM,MAAM,KAAK,IAAI;CACrB,KAAK,MAAM,CAAC,KAAK,UAAU,QACzB,IAAI,MAAM,MAAM,SACd,OAAO,OAAO,GAAG;AAGvB;AAEA,YAAY,SAAS,GAAM,CAAC,CAAC,MAAM;AAEnC,SAAgB,UAAU,SAA2B;CACnD,MAAM,EAAE,UAAU,KAAK,iBAAiB;CAExC,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,MAAM,eAAe,aAAa,CAAC,IAAK,EAAE,IAAI,OAAO,iBAAiB,KAAK;EACjF,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,QAAQ,OAAO,IAAI,GAAG;EAC1B,IAAI,CAAC,SAAS,MAAM,MAAM,SAAS;GACjC,QAAQ;IAAE,OAAO;IAAG,SAAS,MAAM;GAAS;GAC5C,OAAO,IAAI,KAAK,KAAK;EACvB;EACA,MAAM,SAAS;EACf,IAAI,MAAM,QAAQ,KAAK;GACrB,MAAM,aAAa,KAAK,MAAM,MAAM,UAAU,OAAO,GAAI;GACzD,EAAE,OAAO,eAAe,OAAO,UAAU,CAAC;GAC1C,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;EACnD;EACA,EAAE,OAAO,qBAAqB,OAAO,GAAG,CAAC;EACzC,EAAE,OAAO,yBAAyB,OAAO,KAAK,IAAI,GAAG,MAAM,MAAM,KAAK,CAAC,CAAC;EACxE,MAAM,KAAK;CACb;AACF"}
1
+ {"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\ninterface RateLimitOptions {\n windowMs: number;\n max: number;\n keyGenerator?: (c: Context) => string;\n}\n\nconst stores = new Map<string, RateLimitEntry>();\n\nfunction cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of stores) {\n if (now > entry.resetAt) {\n stores.delete(key);\n }\n }\n}\n\nsetInterval(cleanup, 60_000).unref();\n\n/** Hono middleware limiting requests per key within a sliding window. */\nexport function rateLimit(options: RateLimitOptions) {\n const { windowMs, max, keyGenerator } = options;\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const key = keyGenerator ? keyGenerator(c) : (c.req.header(\"x-forwarded-for\") ?? \"anonymous\");\n const now = Date.now();\n let entry = stores.get(key);\n if (!entry || now > entry.resetAt) {\n entry = { count: 0, resetAt: now + windowMs };\n stores.set(key, entry);\n }\n entry.count += 1;\n if (entry.count > max) {\n const retryAfter = Math.ceil((entry.resetAt - now) / 1000);\n c.header(\"Retry-After\", String(retryAfter));\n return c.json({ error: \"Too many requests\" }, 429);\n }\n c.header(\"X-RateLimit-Limit\", String(max));\n c.header(\"X-RateLimit-Remaining\", String(Math.max(0, max - entry.count)));\n await next();\n };\n}\n"],"mappings":";;;;AAaA,MAAM,yBAAS,IAAI,IAA4B;AAE/C,SAAS,UAAgB;CACvB,MAAM,MAAM,KAAK,IAAI;CACrB,KAAK,MAAM,CAAC,KAAK,UAAU,QACzB,IAAI,MAAM,MAAM,SACd,OAAO,OAAO,GAAG;AAGvB;AAEA,YAAY,SAAS,GAAM,CAAC,CAAC,MAAM;;AAGnC,SAAgB,UAAU,SAA2B;CACnD,MAAM,EAAE,UAAU,KAAK,iBAAiB;CAExC,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,MAAM,eAAe,aAAa,CAAC,IAAK,EAAE,IAAI,OAAO,iBAAiB,KAAK;EACjF,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,QAAQ,OAAO,IAAI,GAAG;EAC1B,IAAI,CAAC,SAAS,MAAM,MAAM,SAAS;GACjC,QAAQ;IAAE,OAAO;IAAG,SAAS,MAAM;GAAS;GAC5C,OAAO,IAAI,KAAK,KAAK;EACvB;EACA,MAAM,SAAS;EACf,IAAI,MAAM,QAAQ,KAAK;GACrB,MAAM,aAAa,KAAK,MAAM,MAAM,UAAU,OAAO,GAAI;GACzD,EAAE,OAAO,eAAe,OAAO,UAAU,CAAC;GAC1C,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;EACnD;EACA,EAAE,OAAO,qBAAqB,OAAO,GAAG,CAAC;EACzC,EAAE,OAAO,yBAAyB,OAAO,KAAK,IAAI,GAAG,MAAM,MAAM,KAAK,CAAC,CAAC;EACxE,MAAM,KAAK;CACb;AACF"}
@@ -4,6 +4,7 @@ import { DatabaseAdapter } from "../adapter/database.mjs";
4
4
  import { StorageAdapter } from "../adapter/storage.mjs";
5
5
  import { Baseline } from "../schema.mjs";
6
6
  //#region src/models/baseline.d.ts
7
+ /** Data operations for baseline screenshots. */
7
8
  declare class BaselineModel {
8
9
  private readonly db;
9
10
  private readonly storage;
@@ -1,12 +1,14 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { baselines } from "../schema.mjs";
4
+ import { baselines } from "../schema-tables.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
7
7
  import { baselinePath } from "../utils/paths.mjs";
8
8
  import { and, eq } from "drizzle-orm";
9
9
  //#region src/models/baseline.ts
10
+ /** Per-branch baseline screenshots with default-branch fallback. */
11
+ /** Data operations for baseline screenshots. */
10
12
  var BaselineModel = class {
11
13
  db;
12
14
  storage;
@@ -1 +1 @@
1
- {"version":3,"file":"baseline.mjs","names":[],"sources":["../../src/models/baseline.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { baselines, type Baseline } from \"../schema.ts\";\nimport { baselinePath } from \"../utils/paths.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nexport class BaselineModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n ) {}\n\n async getFor(projectId: string, storyId: string, viewport: string, branch: string): Promise<Baseline | null> {\n const rows = await this.db.list(baselines, {\n where: and(eq(baselines.projectId, projectId), eq(baselines.storyId, storyId), eq(baselines.viewportName, viewport), eq(baselines.branch, branch)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n async resolve(projectId: string, storyId: string, viewport: string, branch: string, defaultBranch: string): Promise<Baseline | null> {\n if (branch !== defaultBranch) {\n const own = await this.getFor(projectId, storyId, viewport, branch);\n if (own) {\n return own;\n }\n }\n return this.getFor(projectId, storyId, viewport, defaultBranch);\n }\n\n async read(baseline: Baseline): Promise<Buffer> {\n return await this.storage.read(baseline.screenshotPath);\n }\n\n async upsert(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n snapshotId: string,\n sourcePath: string,\n ): Promise<Baseline> {\n const screenshotPath = baselinePath(projectId, branch, storyId, viewport);\n const source = await this.storage.read(sourcePath);\n await this.storage.write(screenshotPath, source);\n\n const existing = await this.getFor(projectId, storyId, viewport, branch);\n let baseline: Baseline;\n if (existing) {\n baseline = await this.db.update(baselines, existing.id, {\n snapshotId,\n screenshotPath,\n updatedAt: new Date().toISOString(),\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:updated\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n } else {\n const now = new Date().toISOString();\n baseline = await this.db.insert(baselines, {\n id: ulid(),\n projectId,\n storyId,\n viewportName: viewport,\n branch,\n snapshotId,\n screenshotPath,\n createdAt: now,\n updatedAt: now,\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:created\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n }\n return baseline;\n }\n\n async list(projectId: string): Promise<Baseline[]> {\n return await this.db.list(baselines, { where: eq(baselines.projectId, projectId) });\n }\n\n async removeOrphans(projectId: string, validStoryIds: Set<string>): Promise<number> {\n const all = await this.list(projectId);\n const toRemove = all.filter((baseline) => !validStoryIds.has(baseline.storyId));\n await Promise.all(\n toRemove.map(async (baseline) => {\n await this.db.remove(baselines, baseline.id);\n }),\n );\n return toRemove.length;\n }\n}\n\nexport type { Baseline };\n"],"mappings":";;;;;;;;;AASA,IAAa,gBAAb,MAA2B;CAEN;CACA;CAFnB,YACE,IACA,SACA;EAFiB,KAAA,KAAA;EACA,KAAA,UAAA;CAChB;CAEH,MAAM,OAAO,WAAmB,SAAiB,UAAkB,QAA0C;EAK3G,QAAO,MAJY,KAAK,GAAG,KAAK,WAAW;GACzC,OAAO,IAAI,GAAG,UAAU,WAAW,SAAS,GAAG,GAAG,UAAU,SAAS,OAAO,GAAG,GAAG,UAAU,cAAc,QAAQ,GAAG,GAAG,UAAU,QAAQ,MAAM,CAAC;GACjJ,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;CAEA,MAAM,QAAQ,WAAmB,SAAiB,UAAkB,QAAgB,eAAiD;EACnI,IAAI,WAAW,eAAe;GAC5B,MAAM,MAAM,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;GAClE,IAAI,KACF,OAAO;EAEX;EACA,OAAO,KAAK,OAAO,WAAW,SAAS,UAAU,aAAa;CAChE;CAEA,MAAM,KAAK,UAAqC;EAC9C,OAAO,MAAM,KAAK,QAAQ,KAAK,SAAS,cAAc;CACxD;CAEA,MAAM,OACJ,WACA,SACA,UACA,QACA,YACA,YACmB;EACnB,MAAM,iBAAiB,aAAa,WAAW,QAAQ,SAAS,QAAQ;EACxE,MAAM,SAAS,MAAM,KAAK,QAAQ,KAAK,UAAU;EACjD,MAAM,KAAK,QAAQ,MAAM,gBAAgB,MAAM;EAE/C,MAAM,WAAW,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;EACvE,IAAI;EACJ,IAAI,UAAU;GACZ,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,IAAI;IACtD;IACA;IACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GACpC,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH,OAAO;GACL,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;GACnC,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW;IACzC,IAAI,KAAK;IACT;IACA;IACA,cAAc;IACd;IACA;IACA;IACA,WAAW;IACX,WAAW;GACb,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH;EACA,OAAO;CACT;CAEA,MAAM,KAAK,WAAwC;EACjD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,WAAW,SAAS,EAAE,CAAC;CACpF;CAEA,MAAM,cAAc,WAAmB,eAA6C;EAElF,MAAM,YAAW,MADC,KAAK,KAAK,SAAS,EAAA,CAChB,QAAQ,aAAa,CAAC,cAAc,IAAI,SAAS,OAAO,CAAC;EAC9E,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,aAAa;GAC/B,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,EAAE;EAC7C,CAAC,CACH;EACA,OAAO,SAAS;CAClB;AACF"}
1
+ {"version":3,"file":"baseline.mjs","names":[],"sources":["../../src/models/baseline.ts"],"sourcesContent":["/** Per-branch baseline screenshots with default-branch fallback. */\nimport { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { baselines } from \"../schema-tables.ts\";\nimport type { Baseline } from \"../schema.ts\";\nimport { baselinePath } from \"../utils/paths.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for baseline screenshots. */\nexport class BaselineModel {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n ) {}\n\n async getFor(projectId: string, storyId: string, viewport: string, branch: string): Promise<Baseline | null> {\n const rows = await this.db.list(baselines, {\n where: and(eq(baselines.projectId, projectId), eq(baselines.storyId, storyId), eq(baselines.viewportName, viewport), eq(baselines.branch, branch)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n async resolve(projectId: string, storyId: string, viewport: string, branch: string, defaultBranch: string): Promise<Baseline | null> {\n if (branch !== defaultBranch) {\n const own = await this.getFor(projectId, storyId, viewport, branch);\n if (own) {\n return own;\n }\n }\n return this.getFor(projectId, storyId, viewport, defaultBranch);\n }\n\n async read(baseline: Baseline): Promise<Buffer> {\n return await this.storage.read(baseline.screenshotPath);\n }\n\n async upsert(\n projectId: string,\n storyId: string,\n viewport: string,\n branch: string,\n snapshotId: string,\n sourcePath: string,\n ): Promise<Baseline> {\n const screenshotPath = baselinePath(projectId, branch, storyId, viewport);\n const source = await this.storage.read(sourcePath);\n await this.storage.write(screenshotPath, source);\n\n const existing = await this.getFor(projectId, storyId, viewport, branch);\n let baseline: Baseline;\n if (existing) {\n baseline = await this.db.update(baselines, existing.id, {\n snapshotId,\n screenshotPath,\n updatedAt: new Date().toISOString(),\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:updated\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n } else {\n const now = new Date().toISOString();\n baseline = await this.db.insert(baselines, {\n id: ulid(),\n projectId,\n storyId,\n viewportName: viewport,\n branch,\n snapshotId,\n screenshotPath,\n createdAt: now,\n updatedAt: now,\n });\n await emitWebhookEvent(this.db, projectId, \"baseline:created\", {\n baselineId: baseline.id,\n storyId,\n viewport,\n branch,\n snapshotId,\n });\n }\n return baseline;\n }\n\n async list(projectId: string): Promise<Baseline[]> {\n return await this.db.list(baselines, { where: eq(baselines.projectId, projectId) });\n }\n\n async removeOrphans(projectId: string, validStoryIds: Set<string>): Promise<number> {\n const all = await this.list(projectId);\n const toRemove = all.filter((baseline) => !validStoryIds.has(baseline.storyId));\n await Promise.all(\n toRemove.map(async (baseline) => {\n await this.db.remove(baselines, baseline.id);\n }),\n );\n return toRemove.length;\n }\n}\n\nexport type { Baseline };\n"],"mappings":";;;;;;;;;;;AAYA,IAAa,gBAAb,MAA2B;CAEN;CACA;CAFnB,YACE,IACA,SACA;EAFiB,KAAA,KAAA;EACA,KAAA,UAAA;CAChB;CAEH,MAAM,OAAO,WAAmB,SAAiB,UAAkB,QAA0C;EAK3G,QAAO,MAJY,KAAK,GAAG,KAAK,WAAW;GACzC,OAAO,IAAI,GAAG,UAAU,WAAW,SAAS,GAAG,GAAG,UAAU,SAAS,OAAO,GAAG,GAAG,UAAU,cAAc,QAAQ,GAAG,GAAG,UAAU,QAAQ,MAAM,CAAC;GACjJ,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;CAEA,MAAM,QAAQ,WAAmB,SAAiB,UAAkB,QAAgB,eAAiD;EACnI,IAAI,WAAW,eAAe;GAC5B,MAAM,MAAM,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;GAClE,IAAI,KACF,OAAO;EAEX;EACA,OAAO,KAAK,OAAO,WAAW,SAAS,UAAU,aAAa;CAChE;CAEA,MAAM,KAAK,UAAqC;EAC9C,OAAO,MAAM,KAAK,QAAQ,KAAK,SAAS,cAAc;CACxD;CAEA,MAAM,OACJ,WACA,SACA,UACA,QACA,YACA,YACmB;EACnB,MAAM,iBAAiB,aAAa,WAAW,QAAQ,SAAS,QAAQ;EACxE,MAAM,SAAS,MAAM,KAAK,QAAQ,KAAK,UAAU;EACjD,MAAM,KAAK,QAAQ,MAAM,gBAAgB,MAAM;EAE/C,MAAM,WAAW,MAAM,KAAK,OAAO,WAAW,SAAS,UAAU,MAAM;EACvE,IAAI;EACJ,IAAI,UAAU;GACZ,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,IAAI;IACtD;IACA;IACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GACpC,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH,OAAO;GACL,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;GACnC,WAAW,MAAM,KAAK,GAAG,OAAO,WAAW;IACzC,IAAI,KAAK;IACT;IACA;IACA,cAAc;IACd;IACA;IACA;IACA,WAAW;IACX,WAAW;GACb,CAAC;GACD,MAAM,iBAAiB,KAAK,IAAI,WAAW,oBAAoB;IAC7D,YAAY,SAAS;IACrB;IACA;IACA;IACA;GACF,CAAC;EACH;EACA,OAAO;CACT;CAEA,MAAM,KAAK,WAAwC;EACjD,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,WAAW,SAAS,EAAE,CAAC;CACpF;CAEA,MAAM,cAAc,WAAmB,eAA6C;EAElF,MAAM,YAAW,MADC,KAAK,KAAK,SAAS,EAAA,CAChB,QAAQ,aAAa,CAAC,cAAc,IAAI,SAAS,OAAO,CAAC;EAC9E,MAAM,QAAQ,IACZ,SAAS,IAAI,OAAO,aAAa;GAC/B,MAAM,KAAK,GAAG,OAAO,WAAW,SAAS,EAAE;EAC7C,CAAC,CACH;EACA,OAAO,SAAS;CAClB;AACF"}
@@ -4,6 +4,17 @@ import { BuildStatus } from "../types.mjs";
4
4
  import { DatabaseAdapter } from "../adapter/database.mjs";
5
5
  import { Build } from "../schema.mjs";
6
6
  //#region src/models/build.d.ts
7
+ /**
8
+ * Return whether a build is publicly viewable without a session.
9
+ *
10
+ * A build is public iff `builds.public` is set or its branch matches the
11
+ * project's `public_branch_regex` (ADR 0011). Supplying an empty/nonexistent
12
+ * regex makes every build require auth.
13
+ */
14
+ declare function isPublicBuild(project: {
15
+ publicBranchRegex: string | null;
16
+ }, build: Pick<Build, "public" | "gitBranch">): boolean;
17
+ /** Input for creating a build. */
7
18
  interface BuildCreateInput {
8
19
  gitSha: string;
9
20
  gitBranch: string;
@@ -13,23 +24,30 @@ interface BuildCreateInput {
13
24
  message?: string;
14
25
  public?: boolean;
15
26
  }
27
+ /** Filter options for listing builds. */
16
28
  interface BuildListFilter {
17
29
  status?: BuildStatus;
18
30
  branch?: string;
19
31
  labelKey?: string;
20
32
  labelValue?: string;
21
33
  }
34
+ /** Data operations for build records. */
22
35
  declare class BuildModel {
23
36
  private readonly db;
24
37
  constructor(db: DatabaseAdapter);
25
38
  create(projectId: string, input: BuildCreateInput): Promise<Build>;
26
39
  get(id: string): Promise<Build | null>;
27
40
  list(projectId: string, filter?: BuildListFilter): Promise<Build[]>;
41
+ /** Return the most recent build intended for publishing, if any. */
42
+ latestPublished(project: {
43
+ id: string;
44
+ publicBranchRegex: string | null;
45
+ }): Promise<Build | null>;
28
46
  update(id: string, patch: Partial<Pick<Build, "status" | "public" | "message" | "authorEmail" | "authorName">>): Promise<Build>;
29
47
  setStatus(id: string, status: BuildStatus): Promise<Build>;
30
48
  updateCounts(id: string): Promise<Build>;
31
49
  remove(id: string): Promise<void>;
32
50
  }
33
51
  //#endregion
34
- export { type Build, BuildCreateInput, BuildListFilter, BuildModel };
52
+ export { type Build, BuildCreateInput, BuildListFilter, BuildModel, isPublicBuild };
35
53
  //# sourceMappingURL=build.d.mts.map
@@ -1,10 +1,24 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { buildLabels, builds, snapshots } from "../schema.mjs";
4
+ import { buildLabels, builds, snapshots } from "../schema-tables.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { and, desc, eq, inArray } from "drizzle-orm";
7
7
  //#region src/models/build.ts
8
+ /** Build records, status transitions, and publication helpers. */
9
+ /**
10
+ * Return whether a build is publicly viewable without a session.
11
+ *
12
+ * A build is public iff `builds.public` is set or its branch matches the
13
+ * project's `public_branch_regex` (ADR 0011). Supplying an empty/nonexistent
14
+ * regex makes every build require auth.
15
+ */
16
+ function isPublicBuild(project, build) {
17
+ if (build.public) return true;
18
+ if (!project.publicBranchRegex) return false;
19
+ return new RegExp(project.publicBranchRegex, "u").test(build.gitBranch);
20
+ }
21
+ /** Data operations for build records. */
8
22
  var BuildModel = class {
9
23
  db;
10
24
  constructor(db) {
@@ -44,6 +58,12 @@ var BuildModel = class {
44
58
  orderBy: desc(builds.createdAt)
45
59
  });
46
60
  }
61
+ /** Return the most recent build intended for publishing, if any. */
62
+ async latestPublished(project) {
63
+ const rows = await this.list(project.id);
64
+ for (const build of rows) if (isPublicBuild(project, build)) return build;
65
+ return null;
66
+ }
47
67
  async update(id, patch) {
48
68
  return await this.db.update(builds, id, {
49
69
  ...patch,
@@ -72,6 +92,6 @@ var BuildModel = class {
72
92
  }
73
93
  };
74
94
  //#endregion
75
- export { BuildModel };
95
+ export { BuildModel, isPublicBuild };
76
96
 
77
97
  //# sourceMappingURL=build.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"build.mjs","names":[],"sources":["../../src/models/build.ts"],"sourcesContent":["import { and, desc, eq, inArray } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, snapshots, type Build } from \"../schema.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nexport interface BuildCreateInput {\n gitSha: string;\n gitBranch: string;\n isDefault?: boolean;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n public?: boolean;\n}\n\nexport interface BuildListFilter {\n status?: BuildStatus;\n branch?: string;\n labelKey?: string;\n labelValue?: string;\n}\n\nexport class BuildModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async create(projectId: string, input: BuildCreateInput): Promise<Build> {\n const now = new Date().toISOString();\n return await this.db.insert(builds, {\n id: ulid(),\n projectId,\n gitSha: input.gitSha,\n gitBranch: input.gitBranch,\n isDefault: input.isDefault ?? false,\n authorEmail: input.authorEmail,\n authorName: input.authorName,\n message: input.message,\n public: input.public ?? false,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async get(id: string): Promise<Build | null> {\n return await this.db.get(builds, id);\n }\n\n async list(projectId: string, filter: BuildListFilter = {}): Promise<Build[]> {\n const conditions = [eq(builds.projectId, projectId)];\n if (filter.status) {\n conditions.push(eq(builds.status, filter.status));\n }\n if (filter.branch) {\n conditions.push(eq(builds.gitBranch, filter.branch));\n }\n if (filter.labelKey && filter.labelValue) {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, filter.labelKey), eq(buildLabels.value, filter.labelValue)),\n });\n const ids = labels.map((l) => l.buildId);\n if (ids.length === 0) {\n return [];\n }\n conditions.push(inArray(builds.id, ids));\n }\n return this.db.list(builds, { where: and(...conditions), orderBy: desc(builds.createdAt) });\n }\n\n async update(id: string, patch: Partial<Pick<Build, \"status\" | \"public\" | \"message\" | \"authorEmail\" | \"authorName\">>): Promise<Build> {\n return await this.db.update(builds, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n async setStatus(id: string, status: BuildStatus): Promise<Build> {\n return await this.update(id, { status });\n }\n\n async updateCounts(id: string): Promise<Build> {\n const rows = await this.db.list(snapshots, { where: eq(snapshots.buildId, id) });\n const snapshotCount = rows.length;\n const changedCount = rows.filter((s) => s.status === \"changed\" || s.status === \"new\").length;\n const approvedCount = rows.filter((s) => s.status === \"approved\" || s.status === \"unchanged\").length;\n const rejectedCount = rows.filter((s) => s.status === \"rejected\").length;\n return this.db.update(builds, id, {\n snapshotCount,\n changedCount,\n approvedCount,\n rejectedCount,\n updatedAt: new Date().toISOString(),\n });\n }\n\n async remove(id: string): Promise<void> {\n await this.db.remove(builds, id);\n }\n}\n\nexport type { Build };\n"],"mappings":";;;;;;;AAwBA,IAAa,aAAb,MAAwB;CACO;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,OAAO,WAAmB,OAAyC;EACvE,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ;GAClC,IAAI,KAAK;GACT;GACA,QAAQ,MAAM;GACd,WAAW,MAAM;GACjB,WAAW,MAAM,aAAa;GAC9B,aAAa,MAAM;GACnB,YAAY,MAAM;GAClB,SAAS,MAAM;GACf,QAAQ,MAAM,UAAU;GACxB,QAAQ;GACR,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,IAAI,IAAmC;EAC3C,OAAO,MAAM,KAAK,GAAG,IAAI,QAAQ,EAAE;CACrC;CAEA,MAAM,KAAK,WAAmB,SAA0B,CAAC,GAAqB;EAC5E,MAAM,aAAa,CAAC,GAAG,OAAO,WAAW,SAAS,CAAC;EACnD,IAAI,OAAO,QACT,WAAW,KAAK,GAAG,OAAO,QAAQ,OAAO,MAAM,CAAC;EAElD,IAAI,OAAO,QACT,WAAW,KAAK,GAAG,OAAO,WAAW,OAAO,MAAM,CAAC;EAErD,IAAI,OAAO,YAAY,OAAO,YAAY;GAIxC,MAAM,OAAM,MAHS,KAAK,GAAG,KAAK,aAAa,EAC7C,OAAO,IAAI,GAAG,YAAY,WAAW,SAAS,GAAG,GAAG,YAAY,SAAS,OAAO,QAAQ,GAAG,GAAG,YAAY,OAAO,OAAO,UAAU,CAAC,EACrI,CAAC,EAAA,CACkB,KAAK,MAAM,EAAE,OAAO;GACvC,IAAI,IAAI,WAAW,GACjB,OAAO,CAAC;GAEV,WAAW,KAAK,QAAQ,OAAO,IAAI,GAAG,CAAC;EACzC;EACA,OAAO,KAAK,GAAG,KAAK,QAAQ;GAAE,OAAO,IAAI,GAAG,UAAU;GAAG,SAAS,KAAK,OAAO,SAAS;EAAE,CAAC;CAC5F;CAEA,MAAM,OAAO,IAAY,OAA6G;EACpI,OAAO,MAAM,KAAK,GAAG,OAAO,QAAQ,IAAI;GAAE,GAAG;GAAO,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CAC3F;CAEA,MAAM,UAAU,IAAY,QAAqC;EAC/D,OAAO,MAAM,KAAK,OAAO,IAAI,EAAE,OAAO,CAAC;CACzC;CAEA,MAAM,aAAa,IAA4B;EAC7C,MAAM,OAAO,MAAM,KAAK,GAAG,KAAK,WAAW,EAAE,OAAO,GAAG,UAAU,SAAS,EAAE,EAAE,CAAC;EAC/E,MAAM,gBAAgB,KAAK;EAC3B,MAAM,eAAe,KAAK,QAAQ,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,KAAK,CAAC,CAAC;EACtF,MAAM,gBAAgB,KAAK,QAAQ,MAAM,EAAE,WAAW,cAAc,EAAE,WAAW,WAAW,CAAC,CAAC;EAC9F,MAAM,gBAAgB,KAAK,QAAQ,MAAM,EAAE,WAAW,UAAU,CAAC,CAAC;EAClE,OAAO,KAAK,GAAG,OAAO,QAAQ,IAAI;GAChC;GACA;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;CAEA,MAAM,OAAO,IAA2B;EACtC,MAAM,KAAK,GAAG,OAAO,QAAQ,EAAE;CACjC;AACF"}
1
+ {"version":3,"file":"build.mjs","names":[],"sources":["../../src/models/build.ts"],"sourcesContent":["/** Build records, status transitions, and publication helpers. */\nimport { and, desc, eq, inArray } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, snapshots } from \"../schema-tables.ts\";\nimport type { Build } from \"../schema.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/**\n * Return whether a build is publicly viewable without a session.\n *\n * A build is public iff `builds.public` is set or its branch matches the\n * project's `public_branch_regex` (ADR 0011). Supplying an empty/nonexistent\n * regex makes every build require auth.\n */\nexport function isPublicBuild(project: { publicBranchRegex: string | null }, build: Pick<Build, \"public\" | \"gitBranch\">): boolean {\n if (build.public) {\n return true;\n }\n if (!project.publicBranchRegex) {\n return false;\n }\n return new RegExp(project.publicBranchRegex, \"u\").test(build.gitBranch);\n}\n\n/** Input for creating a build. */\nexport interface BuildCreateInput {\n gitSha: string;\n gitBranch: string;\n isDefault?: boolean;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n public?: boolean;\n}\n\n/** Filter options for listing builds. */\nexport interface BuildListFilter {\n status?: BuildStatus;\n branch?: string;\n labelKey?: string;\n labelValue?: string;\n}\n\n/** Data operations for build records. */\nexport class BuildModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async create(projectId: string, input: BuildCreateInput): Promise<Build> {\n const now = new Date().toISOString();\n return await this.db.insert(builds, {\n id: ulid(),\n projectId,\n gitSha: input.gitSha,\n gitBranch: input.gitBranch,\n isDefault: input.isDefault ?? false,\n authorEmail: input.authorEmail,\n authorName: input.authorName,\n message: input.message,\n public: input.public ?? false,\n status: \"pending\",\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async get(id: string): Promise<Build | null> {\n return await this.db.get(builds, id);\n }\n\n async list(projectId: string, filter: BuildListFilter = {}): Promise<Build[]> {\n const conditions = [eq(builds.projectId, projectId)];\n if (filter.status) {\n conditions.push(eq(builds.status, filter.status));\n }\n if (filter.branch) {\n conditions.push(eq(builds.gitBranch, filter.branch));\n }\n if (filter.labelKey && filter.labelValue) {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, filter.labelKey), eq(buildLabels.value, filter.labelValue)),\n });\n const ids = labels.map((l) => l.buildId);\n if (ids.length === 0) {\n return [];\n }\n conditions.push(inArray(builds.id, ids));\n }\n return this.db.list(builds, { where: and(...conditions), orderBy: desc(builds.createdAt) });\n }\n\n /** Return the most recent build intended for publishing, if any. */\n async latestPublished(project: { id: string; publicBranchRegex: string | null }): Promise<Build | null> {\n const rows = await this.list(project.id);\n for (const build of rows) {\n if (isPublicBuild(project, build)) {\n return build;\n }\n }\n return null;\n }\n\n async update(id: string, patch: Partial<Pick<Build, \"status\" | \"public\" | \"message\" | \"authorEmail\" | \"authorName\">>): Promise<Build> {\n return await this.db.update(builds, id, { ...patch, updatedAt: new Date().toISOString() });\n }\n\n async setStatus(id: string, status: BuildStatus): Promise<Build> {\n return await this.update(id, { status });\n }\n\n async updateCounts(id: string): Promise<Build> {\n const rows = await this.db.list(snapshots, { where: eq(snapshots.buildId, id) });\n const snapshotCount = rows.length;\n const changedCount = rows.filter((s) => s.status === \"changed\" || s.status === \"new\").length;\n const approvedCount = rows.filter((s) => s.status === \"approved\" || s.status === \"unchanged\").length;\n const rejectedCount = rows.filter((s) => s.status === \"rejected\").length;\n return this.db.update(builds, id, {\n snapshotCount,\n changedCount,\n approvedCount,\n rejectedCount,\n updatedAt: new Date().toISOString(),\n });\n }\n\n async remove(id: string): Promise<void> {\n await this.db.remove(builds, id);\n }\n}\n\nexport type { Build };\n"],"mappings":"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"}
@@ -3,11 +3,13 @@ import __tsdown_shims_url from 'node:url';
3
3
  import { DatabaseAdapter } from "../adapter/database.mjs";
4
4
  import { Comment } from "../schema.mjs";
5
5
  //#region src/models/comment.d.ts
6
+ /** Input for creating a review comment. */
6
7
  interface CommentCreateInput {
7
8
  body: string;
8
9
  snapshotId?: string;
9
10
  parentId?: string;
10
11
  }
12
+ /** Data operations for review comments. */
11
13
  declare class CommentModel {
12
14
  private readonly db;
13
15
  constructor(db: DatabaseAdapter);
@@ -1,10 +1,12 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { comments, projects } from "../schema.mjs";
4
+ import { comments, projects } from "../schema-tables.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { eq } from "drizzle-orm";
7
7
  //#region src/models/comment.ts
8
+ /** Build and snapshot review comments. */
9
+ /** Data operations for review comments. */
8
10
  var CommentModel = class {
9
11
  db;
10
12
  constructor(db) {
@@ -1 +1 @@
1
- {"version":3,"file":"comment.mjs","names":[],"sources":["../../src/models/comment.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { comments, projects, type Comment } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nexport interface CommentCreateInput {\n body: string;\n snapshotId?: string;\n parentId?: string;\n}\n\nexport class CommentModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async listByBuild(buildId: string): Promise<Comment[]> {\n return await this.db.list(comments, { where: eq(comments.buildId, buildId) });\n }\n\n async create(projectId: string, buildId: string, userId: string, input: CommentCreateInput): Promise<Comment> {\n const now = new Date().toISOString();\n const project = await this.db.get(projects, projectId);\n if (!project) {\n throw new Error(`Project not found: ${projectId}`);\n }\n return await this.db.insert(comments, {\n id: ulid(),\n projectId,\n buildId,\n snapshotId: input.snapshotId,\n userId,\n body: input.body,\n parentId: input.parentId,\n resolved: false,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async resolve(id: string): Promise<Comment> {\n return await this.db.update(comments, id, { resolved: true, updatedAt: new Date().toISOString() });\n }\n}\n\nexport type { Comment };\n"],"mappings":";;;;;;;AAYA,IAAa,eAAb,MAA0B;CACK;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,YAAY,SAAqC;EACrD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,SAAS,OAAO,EAAE,CAAC;CAC9E;CAEA,MAAM,OAAO,WAAmB,SAAiB,QAAgB,OAA6C;EAC5G,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EAEnC,IAAI,CAAC,MADiB,KAAK,GAAG,IAAI,UAAU,SAAS,GAEnD,MAAM,IAAI,MAAM,sBAAsB,WAAW;EAEnD,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA;GACA,YAAY,MAAM;GAClB;GACA,MAAM,MAAM;GACZ,UAAU,MAAM;GAChB,UAAU;GACV,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,QAAQ,IAA8B;EAC1C,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GAAE,UAAU;GAAM,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CACnG;AACF"}
1
+ {"version":3,"file":"comment.mjs","names":[],"sources":["../../src/models/comment.ts"],"sourcesContent":["/** Build and snapshot review comments. */\nimport { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { comments, projects } from \"../schema-tables.ts\";\nimport type { Comment } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a review comment. */\nexport interface CommentCreateInput {\n body: string;\n snapshotId?: string;\n parentId?: string;\n}\n\n/** Data operations for review comments. */\nexport class CommentModel {\n constructor(private readonly db: DatabaseAdapter) {}\n\n async listByBuild(buildId: string): Promise<Comment[]> {\n return await this.db.list(comments, { where: eq(comments.buildId, buildId) });\n }\n\n async create(projectId: string, buildId: string, userId: string, input: CommentCreateInput): Promise<Comment> {\n const now = new Date().toISOString();\n const project = await this.db.get(projects, projectId);\n if (!project) {\n throw new Error(`Project not found: ${projectId}`);\n }\n return await this.db.insert(comments, {\n id: ulid(),\n projectId,\n buildId,\n snapshotId: input.snapshotId,\n userId,\n body: input.body,\n parentId: input.parentId,\n resolved: false,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n async resolve(id: string): Promise<Comment> {\n return await this.db.update(comments, id, { resolved: true, updatedAt: new Date().toISOString() });\n }\n}\n\nexport type { Comment };\n"],"mappings":";;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CACK;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;CAEnD,MAAM,YAAY,SAAqC;EACrD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,SAAS,OAAO,EAAE,CAAC;CAC9E;CAEA,MAAM,OAAO,WAAmB,SAAiB,QAAgB,OAA6C;EAC5G,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EAEnC,IAAI,CAAC,MADiB,KAAK,GAAG,IAAI,UAAU,SAAS,GAEnD,MAAM,IAAI,MAAM,sBAAsB,WAAW;EAEnD,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA;GACA,YAAY,MAAM;GAClB;GACA,MAAM,MAAM;GACZ,UAAU,MAAM;GAChB,UAAU;GACV,WAAW;GACX,WAAW;EACb,CAAC;CACH;CAEA,MAAM,QAAQ,IAA8B;EAC1C,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU,IAAI;GAAE,UAAU;GAAM,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE,CAAC;CACnG;AACF"}
@@ -27,6 +27,12 @@ declare class LabelModel {
27
27
  linkTemplate?: string;
28
28
  color?: string;
29
29
  }): Promise<LabelType>;
30
+ /** Update a custom label type's name, template or color. */
31
+ updateType(projectId: string, key: string, input: {
32
+ name?: string;
33
+ linkTemplate?: string | null;
34
+ color?: string | null;
35
+ }): Promise<LabelType | null>;
30
36
  /** Remove a custom label type, rejecting reserved or persistent types. */
31
37
  removeType(projectId: string, key: string): Promise<void>;
32
38
  /** Attach a label value to a build. */
@@ -1,11 +1,12 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { buildLabels, builds, labelTypes } from "../schema.mjs";
4
+ import { buildLabels, builds, labelTypes } from "../schema-tables.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from "../types.mjs";
7
7
  import { and, desc, eq } from "drizzle-orm";
8
8
  //#region src/models/label.ts
9
+ /** Build labels and project label types. */
9
10
  const SEEDED_NAMES = {
10
11
  branch: "Branch",
11
12
  persistent: "Persistent",
@@ -68,6 +69,17 @@ var LabelModel = class {
68
69
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
69
70
  });
70
71
  }
72
+ /** Update a custom label type's name, template or color. */
73
+ async updateType(projectId, key, input) {
74
+ if (key === "persistent" || RESERVED_LABEL_KEYS.includes(key)) throw new Error(`Label type '${key}' cannot be updated.`);
75
+ const existing = await this.getType(projectId, key);
76
+ if (!existing) return null;
77
+ return await this.db.update(labelTypes, existing.id, {
78
+ name: input.name ?? existing.name,
79
+ linkTemplate: input.linkTemplate === void 0 ? existing.linkTemplate : input.linkTemplate,
80
+ color: input.color === void 0 ? existing.color : input.color
81
+ });
82
+ }
71
83
  /** Remove a custom label type, rejecting reserved or persistent types. */
72
84
  async removeType(projectId, key) {
73
85
  if (key === "persistent" || RESERVED_LABEL_KEYS.includes(key)) throw new Error(`Label type '${key}' cannot be removed.`);
@@ -1 +1 @@
1
- {"version":3,"file":"label.mjs","names":[],"sources":["../../src/models/label.ts"],"sourcesContent":["import { and, desc, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, labelTypes, type BuildLabel, type LabelType } from \"../schema.ts\";\nimport { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nconst SEEDED_NAMES: Record<string, string> = {\n branch: \"Branch\",\n persistent: \"Persistent\",\n pr: \"Pull request\",\n mr: \"Merge request\",\n jira: \"Jira issue\",\n linear: \"Linear issue\",\n figma: \"Figma file\",\n custom: \"Custom\",\n};\n\n/** Data operations for label types and build labels. */\nexport class LabelModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Seed the default label types for a project if missing.\n *\n * @param projectId - Project ID.\n */\n async seedFor(projectId: string): Promise<void> {\n const now = new Date().toISOString();\n const existing = await this.listTypes(projectId);\n const existingKeys = new Set(existing.map((t) => t.key));\n const missing = SEEDED_LABEL_KEYS.filter((key) => !existingKeys.has(key));\n await Promise.all(\n missing.map(async (key) => {\n await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key,\n name: SEEDED_NAMES[key] ?? key,\n createdAt: now,\n });\n }),\n );\n }\n\n /** List all label types for a project. */\n async listTypes(projectId: string): Promise<LabelType[]> {\n return await this.db.list(labelTypes, { where: eq(labelTypes.projectId, projectId) });\n }\n\n /** Fetch a label type by key, or null if not found. */\n async getType(projectId: string, key: string): Promise<LabelType | null> {\n const rows = await this.db.list(labelTypes, {\n where: and(eq(labelTypes.projectId, projectId), eq(labelTypes.key, key)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Create a custom label type for a project. */\n async createType(projectId: string, input: { key: string; name: string; linkTemplate?: string; color?: string }): Promise<LabelType> {\n return await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key: input.key,\n name: input.name,\n linkTemplate: input.linkTemplate,\n color: input.color,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Remove a custom label type, rejecting reserved or persistent types. */\n async removeType(projectId: string, key: string): Promise<void> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be removed.`);\n }\n const existing = await this.getType(projectId, key);\n if (existing) {\n await this.db.remove(labelTypes, existing.id);\n }\n }\n\n /** Attach a label value to a build. */\n async attach(projectId: string, buildId: string, typeKey: string, value: string): Promise<BuildLabel> {\n return await this.db.insert(buildLabels, {\n id: ulid(),\n projectId,\n buildId,\n typeKey,\n value,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all labels attached to a build. */\n async listForBuild(buildId: string): Promise<BuildLabel[]> {\n return await this.db.list(buildLabels, { where: eq(buildLabels.buildId, buildId) });\n }\n\n /** Return the id of the latest build carrying a given label value, if any. */\n async latestBuildId(projectId: string, typeKey: string, value: string): Promise<string | null> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, typeKey), eq(buildLabels.value, value)),\n });\n if (labels.length === 0) {\n return null;\n }\n const idSet = new Set(labels.map((l) => l.buildId));\n const rows = await this.db.list(builds, { orderBy: desc(builds.createdAt) });\n return rows.find((b) => idSet.has(b.id))?.id ?? null;\n }\n\n /** Return whether a build is marked as persistent. */\n async hasPersistent(projectId: string, buildId: string): Promise<boolean> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.buildId, buildId), eq(buildLabels.typeKey, PERSISTENT_LABEL_KEY)),\n });\n return labels.length > 0;\n }\n}\n\nexport type { BuildLabel, LabelType } from \"../schema.ts\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"label.mjs","names":[],"sources":["../../src/models/label.ts"],"sourcesContent":["/** Build labels and project label types. */\nimport { and, desc, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { buildLabels, builds, labelTypes } from \"../schema-tables.ts\";\nimport type { BuildLabel, LabelType } from \"../schema.ts\";\nimport { PERSISTENT_LABEL_KEY, RESERVED_LABEL_KEYS, SEEDED_LABEL_KEYS } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nconst SEEDED_NAMES: Record<string, string> = {\n branch: \"Branch\",\n persistent: \"Persistent\",\n pr: \"Pull request\",\n mr: \"Merge request\",\n jira: \"Jira issue\",\n linear: \"Linear issue\",\n figma: \"Figma file\",\n custom: \"Custom\",\n};\n\n/** Data operations for label types and build labels. */\nexport class LabelModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /**\n * Seed the default label types for a project if missing.\n *\n * @param projectId - Project ID.\n */\n async seedFor(projectId: string): Promise<void> {\n const now = new Date().toISOString();\n const existing = await this.listTypes(projectId);\n const existingKeys = new Set(existing.map((t) => t.key));\n const missing = SEEDED_LABEL_KEYS.filter((key) => !existingKeys.has(key));\n await Promise.all(\n missing.map(async (key) => {\n await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key,\n name: SEEDED_NAMES[key] ?? key,\n createdAt: now,\n });\n }),\n );\n }\n\n /** List all label types for a project. */\n async listTypes(projectId: string): Promise<LabelType[]> {\n return await this.db.list(labelTypes, { where: eq(labelTypes.projectId, projectId) });\n }\n\n /** Fetch a label type by key, or null if not found. */\n async getType(projectId: string, key: string): Promise<LabelType | null> {\n const rows = await this.db.list(labelTypes, {\n where: and(eq(labelTypes.projectId, projectId), eq(labelTypes.key, key)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Create a custom label type for a project. */\n async createType(projectId: string, input: { key: string; name: string; linkTemplate?: string; color?: string }): Promise<LabelType> {\n return await this.db.insert(labelTypes, {\n id: ulid(),\n projectId,\n key: input.key,\n name: input.name,\n linkTemplate: input.linkTemplate,\n color: input.color,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Update a custom label type's name, template or color. */\n async updateType(projectId: string, key: string, input: { name?: string; linkTemplate?: string | null; color?: string | null }): Promise<LabelType | null> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be updated.`);\n }\n const existing = await this.getType(projectId, key);\n if (!existing) {\n return null;\n }\n return await this.db.update(labelTypes, existing.id, {\n name: input.name ?? existing.name,\n linkTemplate: input.linkTemplate === undefined ? existing.linkTemplate : input.linkTemplate,\n color: input.color === undefined ? existing.color : input.color,\n });\n }\n\n /** Remove a custom label type, rejecting reserved or persistent types. */\n async removeType(projectId: string, key: string): Promise<void> {\n if (key === PERSISTENT_LABEL_KEY || RESERVED_LABEL_KEYS.includes(key as never)) {\n throw new Error(`Label type '${key}' cannot be removed.`);\n }\n const existing = await this.getType(projectId, key);\n if (existing) {\n await this.db.remove(labelTypes, existing.id);\n }\n }\n\n /** Attach a label value to a build. */\n async attach(projectId: string, buildId: string, typeKey: string, value: string): Promise<BuildLabel> {\n return await this.db.insert(buildLabels, {\n id: ulid(),\n projectId,\n buildId,\n typeKey,\n value,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** List all labels attached to a build. */\n async listForBuild(buildId: string): Promise<BuildLabel[]> {\n return await this.db.list(buildLabels, { where: eq(buildLabels.buildId, buildId) });\n }\n\n /** Return the id of the latest build carrying a given label value, if any. */\n async latestBuildId(projectId: string, typeKey: string, value: string): Promise<string | null> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.typeKey, typeKey), eq(buildLabels.value, value)),\n });\n if (labels.length === 0) {\n return null;\n }\n const idSet = new Set(labels.map((l) => l.buildId));\n const rows = await this.db.list(builds, { orderBy: desc(builds.createdAt) });\n return rows.find((b) => idSet.has(b.id))?.id ?? null;\n }\n\n /** Return whether a build is marked as persistent. */\n async hasPersistent(projectId: string, buildId: string): Promise<boolean> {\n const labels = await this.db.list(buildLabels, {\n where: and(eq(buildLabels.projectId, projectId), eq(buildLabels.buildId, buildId), eq(buildLabels.typeKey, PERSISTENT_LABEL_KEY)),\n });\n return labels.length > 0;\n }\n}\n\nexport type { BuildLabel, LabelType } from \"../schema.ts\";\n"],"mappings":"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"}
@@ -1,10 +1,11 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projectMembers } from "../schema.mjs";
4
+ import { projectMembers } from "../schema-tables.mjs";
5
5
  import { ulid } from "../utils/ulid.mjs";
6
6
  import { and, eq } from "drizzle-orm";
7
7
  //#region src/models/member.ts
8
+ /** Project membership and role resolution. */
8
9
  /** Data operations for project membership and roles. */
9
10
  var MemberModel = class {
10
11
  db;
@@ -1 +1 @@
1
- {"version":3,"file":"member.mjs","names":[],"sources":["../../src/models/member.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectMembers, type ProjectMember } from \"../schema.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for project membership and roles. */\nexport class MemberModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all members of a project. */\n async list(projectId: string): Promise<ProjectMember[]> {\n return await this.db.list(projectMembers, { where: eq(projectMembers.projectId, projectId) });\n }\n\n /** Fetch a project member by project and user id, or null if not found. */\n async get(projectId: string, userId: string): Promise<ProjectMember | null> {\n const rows = await this.db.list(projectMembers, {\n where: and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Set a user's role in a project, creating the membership if needed. */\n async set(projectId: string, userId: string, role: ProjectRole): Promise<ProjectMember> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n return this.db.update(projectMembers, existing.id, { role });\n }\n return this.db.insert(projectMembers, {\n id: ulid(),\n projectId,\n userId,\n role,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Remove a user from a project if they are a member. */\n async remove(projectId: string, userId: string): Promise<void> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n await this.db.remove(projectMembers, existing.id);\n }\n }\n\n /** Resolve a user's effective project role, honoring site-wide admins. */\n async effectiveRole(siteRole: SiteRole, projectId: string, userId: string): Promise<ProjectRole | null> {\n if (siteRole === \"admin\") {\n return \"admin\";\n }\n const member = await this.get(projectId, userId);\n return member?.role ?? null;\n }\n}\n\nexport type { ProjectMember };\n"],"mappings":";;;;;;;;AAQA,IAAa,cAAb,MAAyB;CAIM;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAA6C;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,gBAAgB,EAAE,OAAO,GAAG,eAAe,WAAW,SAAS,EAAE,CAAC;CAC9F;;CAGA,MAAM,IAAI,WAAmB,QAA+C;EAK1E,QAAO,MAJY,KAAK,GAAG,KAAK,gBAAgB;GAC9C,OAAO,IAAI,GAAG,eAAe,WAAW,SAAS,GAAG,GAAG,eAAe,QAAQ,MAAM,CAAC;GACrF,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,IAAI,WAAmB,QAAgB,MAA2C;EACtF,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,OAAO,KAAK,GAAG,OAAO,gBAAgB,SAAS,IAAI,EAAE,KAAK,CAAC;EAE7D,OAAO,KAAK,GAAG,OAAO,gBAAgB;GACpC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,WAAmB,QAA+B;EAC7D,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,gBAAgB,SAAS,EAAE;CAEpD;;CAGA,MAAM,cAAc,UAAoB,WAAmB,QAA6C;EACtG,IAAI,aAAa,SACf,OAAO;EAGT,QAAO,MADc,KAAK,IAAI,WAAW,MAAM,EAAA,EAChC,QAAQ;CACzB;AACF"}
1
+ {"version":3,"file":"member.mjs","names":[],"sources":["../../src/models/member.ts"],"sourcesContent":["/** Project membership and role resolution. */\nimport { and, eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { projectMembers } from \"../schema-tables.ts\";\nimport type { ProjectMember } from \"../schema.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Data operations for project membership and roles. */\nexport class MemberModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all members of a project. */\n async list(projectId: string): Promise<ProjectMember[]> {\n return await this.db.list(projectMembers, { where: eq(projectMembers.projectId, projectId) });\n }\n\n /** Fetch a project member by project and user id, or null if not found. */\n async get(projectId: string, userId: string): Promise<ProjectMember | null> {\n const rows = await this.db.list(projectMembers, {\n where: and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)),\n limit: 1,\n });\n return rows[0] ?? null;\n }\n\n /** Set a user's role in a project, creating the membership if needed. */\n async set(projectId: string, userId: string, role: ProjectRole): Promise<ProjectMember> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n return this.db.update(projectMembers, existing.id, { role });\n }\n return this.db.insert(projectMembers, {\n id: ulid(),\n projectId,\n userId,\n role,\n createdAt: new Date().toISOString(),\n });\n }\n\n /** Remove a user from a project if they are a member. */\n async remove(projectId: string, userId: string): Promise<void> {\n const existing = await this.get(projectId, userId);\n if (existing) {\n await this.db.remove(projectMembers, existing.id);\n }\n }\n\n /** Resolve a user's effective project role, honoring site-wide admins. */\n async effectiveRole(siteRole: SiteRole, projectId: string, userId: string): Promise<ProjectRole | null> {\n if (siteRole === \"admin\") {\n return \"admin\";\n }\n const member = await this.get(projectId, userId);\n return member?.role ?? null;\n }\n}\n\nexport type { ProjectMember };\n"],"mappings":";;;;;;;;;AAUA,IAAa,cAAb,MAAyB;CAIM;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAA6C;EACtD,OAAO,MAAM,KAAK,GAAG,KAAK,gBAAgB,EAAE,OAAO,GAAG,eAAe,WAAW,SAAS,EAAE,CAAC;CAC9F;;CAGA,MAAM,IAAI,WAAmB,QAA+C;EAK1E,QAAO,MAJY,KAAK,GAAG,KAAK,gBAAgB;GAC9C,OAAO,IAAI,GAAG,eAAe,WAAW,SAAS,GAAG,GAAG,eAAe,QAAQ,MAAM,CAAC;GACrF,OAAO;EACT,CAAC,EAAA,CACW,MAAM;CACpB;;CAGA,MAAM,IAAI,WAAmB,QAAgB,MAA2C;EACtF,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,OAAO,KAAK,GAAG,OAAO,gBAAgB,SAAS,IAAI,EAAE,KAAK,CAAC;EAE7D,OAAO,KAAK,GAAG,OAAO,gBAAgB;GACpC,IAAI,KAAK;GACT;GACA;GACA;GACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EACpC,CAAC;CACH;;CAGA,MAAM,OAAO,WAAmB,QAA+B;EAC7D,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,MAAM;EACjD,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,gBAAgB,SAAS,EAAE;CAEpD;;CAGA,MAAM,cAAc,UAAoB,WAAmB,QAA6C;EACtG,IAAI,aAAa,SACf,OAAO;EAGT,QAAO,MADc,KAAK,IAAI,WAAW,MAAM,EAAA,EAChC,QAAQ;CACzB;AACF"}
@@ -3,6 +3,7 @@ import __tsdown_shims_url from 'node:url';
3
3
  import { DatabaseAdapter } from "../adapter/database.mjs";
4
4
  import { Project } from "../schema.mjs";
5
5
  //#region src/models/project.d.ts
6
+ /** Input for creating a project. */
6
7
  interface ProjectCreateInput {
7
8
  name: string;
8
9
  gitRepository?: string;
@@ -1,10 +1,11 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { projects } from "../schema.mjs";
4
+ import { projects } from "../schema-tables.mjs";
5
5
  import { slugify, ulid } from "../utils/ulid.mjs";
6
6
  import { eq } from "drizzle-orm";
7
7
  //#region src/models/project.ts
8
+ /** Project records and slug management. */
8
9
  /** Data operations for project records. */
9
10
  var ProjectModel = class {
10
11
  db;
@@ -52,7 +53,7 @@ var ProjectModel = class {
52
53
  /** Update mutable fields of a project. */
53
54
  async update(id, patch) {
54
55
  const normalized = { ...patch };
55
- if (patch.storybookMeta !== void 0 && patch.storybookMeta !== null && typeof patch.storybookMeta !== "string") normalized["storybookMeta"] = JSON.stringify(patch.storybookMeta);
56
+ if (patch.storybookMeta !== void 0 && patch.storybookMeta !== null && typeof patch.storybookMeta !== "string") normalized.storybookMeta = JSON.stringify(patch.storybookMeta);
56
57
  return await this.db.update(projects, id, {
57
58
  ...normalized,
58
59
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()