@storyshelf/core 0.1.3 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -46
- package/dist/adapter/auth.d.mts +4 -4
- package/dist/adapter/auth.mjs.map +1 -1
- package/dist/{adapters → adapter}/capture-queue.d.mts +4 -4
- package/dist/adapter/capture-queue.mjs +4 -0
- package/dist/adapter/capture-runner.d.mts +7 -7
- package/dist/adapter/database.d.mts +20 -9
- package/dist/adapter/database.mjs +60 -1
- package/dist/adapter/database.mjs.map +1 -0
- package/dist/adapter/git-host/comments.d.mts +41 -0
- package/dist/adapter/git-host/comments.mjs +54 -0
- package/dist/adapter/git-host/comments.mjs.map +1 -0
- package/dist/adapter/git-host.d.mts +3 -7
- package/dist/adapter/init.d.mts +58 -0
- package/dist/adapter/init.mjs +93 -0
- package/dist/adapter/init.mjs.map +1 -0
- package/dist/adapter/metadata.d.mts +83 -0
- package/dist/adapter/metadata.mjs +4 -0
- package/dist/adapter/storage.d.mts +15 -4
- package/dist/adapters/webhook-events.mjs +12 -4
- package/dist/adapters/webhook-events.mjs.map +1 -1
- package/dist/capture/adapter.d.mts +5 -1
- package/dist/capture/dispatch.mjs +150 -0
- package/dist/capture/dispatch.mjs.map +1 -0
- package/dist/capture/orchestrator.d.mts +2 -0
- package/dist/capture/orchestrator.mjs +5 -44
- package/dist/capture/orchestrator.mjs.map +1 -1
- package/dist/capture/pipeline.d.mts +4 -1
- package/dist/capture/pipeline.mjs +6 -6
- package/dist/capture/pipeline.mjs.map +1 -1
- package/dist/capture/queue.d.mts +2 -1
- package/dist/capture/queue.mjs +2 -1
- package/dist/capture/queue.mjs.map +1 -1
- package/dist/capture/skip-checks.mjs +76 -0
- package/dist/capture/skip-checks.mjs.map +1 -0
- package/dist/capture/statics.mjs +124 -0
- package/dist/capture/statics.mjs.map +1 -0
- package/dist/capture/status-fanout.mjs.map +1 -1
- package/dist/capture/storybook.mjs.map +1 -1
- package/dist/capture.d.mts +8 -0
- package/dist/capture.mjs +9 -0
- package/dist/config.d.mts +10 -2
- package/dist/config.mjs +14 -2
- package/dist/config.mjs.map +1 -1
- package/dist/ddl.d.mts +1 -1
- package/dist/ddl.mjs +1 -1
- package/dist/ddl.mjs.map +1 -1
- package/dist/diff/engine.mjs.map +1 -1
- package/dist/diff/options.d.mts +3 -1
- package/dist/diff.d.mts +5 -0
- package/dist/diff.mjs +6 -0
- package/dist/index.d.mts +32 -25
- package/dist/index.mjs +207 -253
- package/dist/index.mjs.map +1 -1
- package/dist/logger.d.mts +3 -3
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +28 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +29 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/models/baseline.mjs +7 -5
- package/dist/models/baseline.mjs.map +1 -1
- package/dist/models/build.mjs +3 -1
- package/dist/models/build.mjs.map +1 -1
- package/dist/models/comment.mjs +2 -1
- package/dist/models/comment.mjs.map +1 -1
- package/dist/models/label.mjs +2 -1
- package/dist/models/label.mjs.map +1 -1
- package/dist/models/member.mjs +1 -1
- package/dist/models/member.mjs.map +1 -1
- package/dist/models/project.mjs +1 -1
- package/dist/models/project.mjs.map +1 -1
- package/dist/models/snapshot.mjs +1 -1
- package/dist/models/snapshot.mjs.map +1 -1
- package/dist/models/status-config.mjs +1 -1
- package/dist/models/status-config.mjs.map +1 -1
- package/dist/models/token.mjs +1 -1
- package/dist/models/token.mjs.map +1 -1
- package/dist/models/webhook.mjs +11 -3
- package/dist/models/webhook.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +9 -4
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +113 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +163 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +66 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +197 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +65 -413
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/compute-jobs.mjs +1 -1
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/label-detail.mjs +4 -3
- package/dist/pages/label-detail.mjs.map +1 -1
- package/dist/pages/login.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +5 -3
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-create.mjs.map +1 -1
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects.mjs +11 -6
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/root.mjs +6 -3
- package/dist/pages/root.mjs.map +1 -1
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +1 -1
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-members.mjs +1 -1
- package/dist/pages/settings-members.mjs.map +1 -1
- package/dist/pages/settings-status.mjs +2 -1
- package/dist/pages/settings-status.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +3 -3
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/settings-tokens.mjs.map +1 -1
- package/dist/pages/settings-webhooks.mjs.map +1 -1
- package/dist/pages/storybook.mjs +38 -1
- package/dist/pages/storybook.mjs.map +1 -1
- package/dist/paths.mjs.map +1 -0
- package/dist/retention/purge.mjs +6 -3
- package/dist/retention/purge.mjs.map +1 -1
- package/dist/routers/admin.mjs +1 -1
- package/dist/routers/admin.mjs.map +1 -1
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/auth.mjs.map +1 -1
- package/dist/routers/builds.handlers.mjs +106 -21
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +54 -34
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/health.mjs +137 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +2 -2
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/htmx.mjs.map +1 -1
- package/dist/routers/labels.mjs +1 -1
- package/dist/routers/labels.mjs.map +1 -1
- package/dist/routers/media.mjs +2 -2
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/projects.mjs +1 -1
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/settings-general.mjs +95 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +49 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +30 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +73 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +63 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +93 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +13 -247
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/snapshots.mjs +1 -1
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/status-configs.mjs +1 -1
- package/dist/routers/status-configs.mjs.map +1 -1
- package/dist/routers/storybook.mjs +11 -4
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +1 -1
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +4 -4
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +6 -6
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/schema/baseline.d.mts +18 -0
- package/dist/schema/baseline.mjs +22 -0
- package/dist/schema/baseline.mjs.map +1 -0
- package/dist/schema/build.d.mts +26 -0
- package/dist/schema/build.mjs +29 -0
- package/dist/schema/build.mjs.map +1 -0
- package/dist/schema/comment.d.mts +19 -0
- package/dist/schema/comment.mjs +26 -0
- package/dist/schema/comment.mjs.map +1 -0
- package/dist/schema/label.d.mts +25 -0
- package/dist/schema/label.mjs +30 -0
- package/dist/schema/label.mjs.map +1 -0
- package/dist/schema/member.d.mts +15 -0
- package/dist/schema/member.mjs +19 -0
- package/dist/schema/member.mjs.map +1 -0
- package/dist/schema/project.d.mts +34 -0
- package/dist/schema/project.mjs +25 -0
- package/dist/schema/project.mjs.map +1 -0
- package/dist/schema/snapshot.d.mts +30 -0
- package/dist/schema/snapshot.mjs +34 -0
- package/dist/schema/snapshot.mjs.map +1 -0
- package/dist/schema/status-config.d.mts +16 -0
- package/dist/schema/status-config.mjs +20 -0
- package/dist/schema/status-config.mjs.map +1 -0
- package/dist/schema/token.d.mts +15 -0
- package/dist/schema/token.mjs +19 -0
- package/dist/schema/token.mjs.map +1 -0
- package/dist/schema/user.d.mts +17 -0
- package/dist/schema/user.mjs +19 -0
- package/dist/schema/user.mjs.map +1 -0
- package/dist/schema/webhook.d.mts +16 -0
- package/dist/schema/webhook.mjs +20 -0
- package/dist/schema/webhook.mjs.map +1 -0
- package/dist/schema.d.mts +13 -168
- package/dist/schema.mjs +12 -2
- package/dist/schema.mjs.map +1 -1
- package/dist/store.mjs.map +1 -1
- package/dist/types.d.mts +1 -2
- package/dist/types.mjs.map +1 -1
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/feedback.mjs +24 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/{components.mjs → forms.mjs} +3 -18
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/styles.mjs.map +1 -1
- package/dist/urls.mjs.map +1 -1
- package/package.json +47 -45
- package/dist/adapters/metadata.d.mts +0 -24
- package/dist/models/baseline.d.mts +0 -21
- package/dist/models/build.d.mts +0 -53
- package/dist/models/comment.d.mts +0 -22
- package/dist/models/label.d.mts +0 -49
- package/dist/models/member.d.mts +0 -27
- package/dist/models/project.d.mts +0 -43
- package/dist/models/snapshot.d.mts +0 -47
- package/dist/models/token.d.mts +0 -33
- package/dist/models/webhook.d.mts +0 -38
- package/dist/retention/purge.d.mts +0 -31
- package/dist/schema-tables.mjs +0 -166
- package/dist/schema-tables.mjs.map +0 -1
- package/dist/ui/components.mjs.map +0 -1
- package/dist/ui/document.d.mts +0 -9
- package/dist/utils/paths.mjs.map +0 -1
- package/dist/utils/ulid.d.mts +0 -10
- /package/dist/{utils/paths.d.mts → paths.d.mts} +0 -0
- /package/dist/{utils/paths.mjs → paths.mjs} +0 -0
|
@@ -3,13 +3,17 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { BuildModel } from "../models/build.mjs";
|
|
5
5
|
import { ProjectModel } from "../models/project.mjs";
|
|
6
|
-
import { BUILD_STATUSES } from "../types.mjs";
|
|
7
|
-
import { getStore } from "../store.mjs";
|
|
8
|
-
import { notFound } from "./helpers.mjs";
|
|
9
6
|
import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
|
|
10
7
|
import { BaselineModel } from "../models/baseline.mjs";
|
|
11
8
|
import { SnapshotModel } from "../models/snapshot.mjs";
|
|
9
|
+
import { extractStorybookToScratch, persistStorybookStatics } from "../capture/statics.mjs";
|
|
10
|
+
import { getStore } from "../store.mjs";
|
|
11
|
+
import { BUILD_STATUSES } from "../types.mjs";
|
|
12
|
+
import { LabelModel } from "../models/label.mjs";
|
|
13
|
+
import { notFound } from "./helpers.mjs";
|
|
12
14
|
import { z } from "@hono/zod-openapi";
|
|
15
|
+
import { HTTPException } from "hono/http-exception";
|
|
16
|
+
import { Readable, Transform } from "node:stream";
|
|
13
17
|
//#region src/routers/builds.handlers.ts
|
|
14
18
|
/** Roles permitted to view builds, snapshots, and comments. */
|
|
15
19
|
const VIEW_ROLES = [
|
|
@@ -26,18 +30,103 @@ const DEVELOPER_ROLES = [
|
|
|
26
30
|
];
|
|
27
31
|
/** Roles permitted to approve or reject snapshots and delete builds. */
|
|
28
32
|
const APPROVER_ROLES = ["approver", "admin"];
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
31
|
-
gitSha: z.string(),
|
|
32
|
-
gitBranch: z.string(),
|
|
33
|
+
/** JSON input schema for creating a build before streaming its bundle. */
|
|
34
|
+
const buildCreateJsonSchema = z.object({
|
|
35
|
+
gitSha: z.string().min(1),
|
|
36
|
+
gitBranch: z.string().min(1),
|
|
33
37
|
authorEmail: z.string().optional(),
|
|
34
38
|
authorName: z.string().optional(),
|
|
35
39
|
message: z.string().optional(),
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}).optional()
|
|
40
|
-
}).openapi("
|
|
40
|
+
labels: z.array(z.object({
|
|
41
|
+
key: z.string().min(1),
|
|
42
|
+
value: z.string()
|
|
43
|
+
})).optional()
|
|
44
|
+
}).openapi("BuildCreate");
|
|
45
|
+
async function attachLabels(db, projectId, buildId, labels) {
|
|
46
|
+
const models = new LabelModel(db);
|
|
47
|
+
for (const { key, value } of labels) {
|
|
48
|
+
if (!await models.getType(projectId, key)) await models.createType(projectId, {
|
|
49
|
+
key,
|
|
50
|
+
name: key
|
|
51
|
+
});
|
|
52
|
+
await models.attach(projectId, buildId, key, value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create a build record with labels and the `build:created` webhook.
|
|
57
|
+
* Shared by the build-creation route.
|
|
58
|
+
*/
|
|
59
|
+
async function createBuildRecord(project, meta) {
|
|
60
|
+
const { db, config } = getStore();
|
|
61
|
+
if (!meta.gitSha || !meta.gitBranch) throw new HTTPException(400, { message: "gitSha and gitBranch are required" });
|
|
62
|
+
const build = await new BuildModel(db).create(project.id, {
|
|
63
|
+
gitSha: meta.gitSha,
|
|
64
|
+
gitBranch: meta.gitBranch,
|
|
65
|
+
isDefault: meta.gitBranch === project.gitDefaultBranch,
|
|
66
|
+
authorEmail: meta.authorEmail,
|
|
67
|
+
authorName: meta.authorName,
|
|
68
|
+
message: meta.message
|
|
69
|
+
});
|
|
70
|
+
if (meta.labels && meta.labels.length > 0) await attachLabels(db, project.id, build.id, meta.labels);
|
|
71
|
+
await emitWebhookEvent(db, project.id, "build:created", {
|
|
72
|
+
buildId: build.id,
|
|
73
|
+
gitSha: meta.gitSha,
|
|
74
|
+
gitBranch: meta.gitBranch,
|
|
75
|
+
authorEmail: meta.authorEmail,
|
|
76
|
+
authorName: meta.authorName,
|
|
77
|
+
message: meta.message
|
|
78
|
+
}, config.secret);
|
|
79
|
+
return build;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Stream a raw request body into storage with a byte cap.
|
|
83
|
+
* Throws 400 for a missing body, 413 once `maxBytes` is exceeded.
|
|
84
|
+
*/
|
|
85
|
+
async function storeUploadStream(storage, path, body, maxBytes) {
|
|
86
|
+
if (!body) throw new HTTPException(400, { message: "Missing request body" });
|
|
87
|
+
let seen = 0;
|
|
88
|
+
const limiter = new Transform({ transform(chunk, _encoding, callback) {
|
|
89
|
+
seen += chunk.length;
|
|
90
|
+
if (seen > maxBytes) callback(/* @__PURE__ */ new Error(`Upload exceeds ${maxBytes} byte limit`));
|
|
91
|
+
else callback(null, chunk);
|
|
92
|
+
} });
|
|
93
|
+
try {
|
|
94
|
+
const nodeBody = Readable.fromWeb(body);
|
|
95
|
+
await storage.writeStream(path, nodeBody.pipe(limiter));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (seen > maxBytes) throw new HTTPException(413, { message: "Upload exceeds size limit" });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Parse a Content-Length header value into bytes, or undefined when unknown. */
|
|
102
|
+
function parseContentLength(value) {
|
|
103
|
+
if (!value) return;
|
|
104
|
+
const parsed = Math.trunc(Number(value));
|
|
105
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Best-effort inline statics extraction for small uploads (see
|
|
109
|
+
* `maxInlineUnzipSize`). Never throws: failures are logged and the capture
|
|
110
|
+
* job backfills the same keys idempotently.
|
|
111
|
+
*/
|
|
112
|
+
async function persistInlineStatics(storage, scratchDir, maxInlineBytes, contentLength, projectId, buildId, logger) {
|
|
113
|
+
if (!scratchDir || !maxInlineBytes) return;
|
|
114
|
+
const size = parseContentLength(contentLength);
|
|
115
|
+
if (size === void 0 || size > maxInlineBytes) return;
|
|
116
|
+
try {
|
|
117
|
+
const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);
|
|
118
|
+
await persistStorybookStatics(storage, extractedDir, projectId, buildId);
|
|
119
|
+
logger.info({
|
|
120
|
+
buildId,
|
|
121
|
+
size
|
|
122
|
+
}, "upload statics persisted inline");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.error({
|
|
125
|
+
err: error,
|
|
126
|
+
buildId
|
|
127
|
+
}, "inline statics persist failed; capture will backfill");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
41
130
|
/** Query filters accepted by the build list endpoint. */
|
|
42
131
|
const buildListQuery = z.object({
|
|
43
132
|
status: z.enum(BUILD_STATUSES).optional(),
|
|
@@ -59,7 +148,7 @@ async function snapshotForBuild(build, snapshotId) {
|
|
|
59
148
|
}
|
|
60
149
|
/** Recompute a build's counts and roll its status up from its snapshots. */
|
|
61
150
|
async function refreshBuild(buildId) {
|
|
62
|
-
const { db } = getStore();
|
|
151
|
+
const { db, config } = getStore();
|
|
63
152
|
await new BuildModel(db).updateCounts(buildId);
|
|
64
153
|
const snapshots = await new SnapshotModel(db).listByBuild(buildId);
|
|
65
154
|
const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
|
|
@@ -73,12 +162,12 @@ async function refreshBuild(buildId) {
|
|
|
73
162
|
buildId,
|
|
74
163
|
status,
|
|
75
164
|
snapshotCount: snapshots.length
|
|
76
|
-
});
|
|
165
|
+
}, config.secret);
|
|
77
166
|
}
|
|
78
167
|
}
|
|
79
168
|
/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */
|
|
80
169
|
async function approveSnapshot(snapshotId, userId) {
|
|
81
|
-
const { db } = getStore();
|
|
170
|
+
const { db, config } = getStore();
|
|
82
171
|
const snapshots = new SnapshotModel(db);
|
|
83
172
|
const snapshot = await snapshots.get(snapshotId);
|
|
84
173
|
if (!snapshot) notFound("Snapshot not found");
|
|
@@ -86,14 +175,10 @@ async function approveSnapshot(snapshotId, userId) {
|
|
|
86
175
|
const build = await new BuildModel(db).get(snapshot.buildId);
|
|
87
176
|
if (!project || !build) notFound("Project or build not found");
|
|
88
177
|
await snapshots.review(snapshotId, "approved", userId);
|
|
89
|
-
await new BaselineModel(db, getStore().storage).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
|
|
178
|
+
await new BaselineModel(db, getStore().storage, config.secret).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
|
|
90
179
|
await refreshBuild(build.id);
|
|
91
180
|
}
|
|
92
|
-
/** Extract a string from a multipart form field, ignoring file entries. */
|
|
93
|
-
function asString(value) {
|
|
94
|
-
return typeof value === "string" ? value : void 0;
|
|
95
|
-
}
|
|
96
181
|
//#endregion
|
|
97
|
-
export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot,
|
|
182
|
+
export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, refreshBuild, snapshotForBuild, storeUploadStream };
|
|
98
183
|
|
|
99
184
|
//# sourceMappingURL=builds.handlers.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** Multipart input schema for uploading a build with its Storybook bundle. */\nexport const buildUploadSchema = z.object({\n gitSha: z.string(),\n gitBranch: z.string(),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n zip: z.instanceof(File).openapi({ type: \"string\", format: \"binary\" }).optional(),\n}).openapi(\"BuildUpload\");\n\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(projectId: string, buildId: string): Promise<import(\"../models/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(build: { id: string }, snapshotId: string): Promise<import(\"../models/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const {db} = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(db, build.projectId, `build:${status}`, {\n buildId,\n status,\n snapshotCount: snapshots.length,\n });\n }\n}\n\n/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const {db} = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage);\n await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);\n await refreshBuild(build.id);\n}\n\n/** Extract a string from a multipart form field, ignoring file entries. */\nfunction asString(value: FormDataEntryValue | null): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\nexport { asString };"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { Readable, Transform } from \"node:stream\";\nimport type { ReadableStream as NodeWebStream } from \"node:stream/web\";\nimport type { Logger } from \"pino\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"../capture/statics.ts\";\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { LabelModel } from \"../models/label.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** JSON input schema for creating a build before streaming its bundle. */\nexport const buildCreateJsonSchema = z\n .object({\n gitSha: z.string().min(1),\n gitBranch: z.string().min(1),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n labels: z.array(z.object({ key: z.string().min(1), value: z.string() })).optional(),\n })\n .openapi(\"BuildCreate\");\n\n/** Metadata accepted by either build-creation route. */\nexport interface BuildCreateMetadata {\n gitSha: string;\n gitBranch: string;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n labels?: { key: string; value: string }[];\n}\n\n/* oxlint-disable eslint/no-await-in-loop -- label attach is intentionally sequential */\nasync function attachLabels(\n db: import(\"../adapters/database.ts\").DatabaseAdapter,\n projectId: string,\n buildId: string,\n labels: { key: string; value: string }[],\n): Promise<void> {\n const models = new LabelModel(db);\n for (const { key, value } of labels) {\n const existing = await models.getType(projectId, key);\n if (!existing) {\n await models.createType(projectId, { key, name: key });\n }\n await models.attach(projectId, buildId, key, value);\n }\n}\n/* oxlint-enable eslint/no-await-in-loop */\n\n/**\n * Create a build record with labels and the `build:created` webhook.\n * Shared by the build-creation route.\n */\nexport async function createBuildRecord(\n project: Project,\n meta: BuildCreateMetadata,\n): Promise<import(\"../schema/build.ts\").Build> {\n const { db, config } = getStore();\n if (!meta.gitSha || !meta.gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const build = await new BuildModel(db).create(project.id, {\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n isDefault: meta.gitBranch === project.gitDefaultBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n });\n if (meta.labels && meta.labels.length > 0) {\n await attachLabels(db, project.id, build.id, meta.labels);\n }\n await emitWebhookEvent(\n db,\n project.id,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n config.secret,\n );\n return build;\n}\n\n/**\n * Stream a raw request body into storage with a byte cap.\n * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.\n */\nexport async function storeUploadStream(\n storage: StorageAdapter,\n path: string,\n body: ReadableStream<Uint8Array> | null,\n maxBytes: number,\n): Promise<void> {\n if (!body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n let seen = 0;\n const limiter = new Transform({\n transform(\n chunk: Buffer,\n _encoding: string,\n callback: (error?: Error | null, data?: Buffer) => void,\n ): void {\n seen += chunk.length;\n if (seen > maxBytes) {\n callback(new Error(`Upload exceeds ${maxBytes} byte limit`));\n } else {\n callback(null, chunk);\n }\n },\n });\n try {\n const nodeBody = Readable.fromWeb(body as unknown as NodeWebStream);\n await storage.writeStream(path, nodeBody.pipe(limiter));\n } catch (error) {\n if (seen > maxBytes) {\n throw new HTTPException(413, { message: \"Upload exceeds size limit\" });\n }\n throw error;\n }\n}\n\n/** Parse a Content-Length header value into bytes, or undefined when unknown. */\nfunction parseContentLength(value: string | undefined): number | undefined {\n if (!value) {\n return undefined;\n }\n const parsed = Math.trunc(Number(value));\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n\n/**\n * Best-effort inline statics extraction for small uploads (see\n * `maxInlineUnzipSize`). Never throws: failures are logged and the capture\n * job backfills the same keys idempotently.\n */\nexport async function persistInlineStatics(\n storage: StorageAdapter,\n scratchDir: string | undefined,\n maxInlineBytes: number | undefined,\n contentLength: string | undefined,\n projectId: string,\n buildId: string,\n logger: Logger,\n): Promise<void> {\n if (!scratchDir || !maxInlineBytes) {\n return;\n }\n const size = parseContentLength(contentLength);\n if (size === undefined || size > maxInlineBytes) {\n return;\n }\n try {\n const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);\n await persistStorybookStatics(storage, extractedDir, projectId, buildId);\n logger.info({ buildId, size }, \"upload statics persisted inline\");\n } catch (error) {\n logger.error({ err: error, buildId }, \"inline statics persist failed; capture will backfill\");\n }\n}\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(\n projectId: string,\n buildId: string,\n): Promise<import(\"../schema/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(\n build: { id: string },\n snapshotId: string,\n): Promise<import(\"../schema/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const { db, config } = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(\n db,\n build.projectId,\n `build:${status}`,\n {\n buildId,\n status,\n snapshotCount: snapshots.length,\n },\n config.secret,\n );\n }\n}\n\n/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const { db, config } = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage, config.secret);\n await baselines.upsert(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n snapshot.id,\n snapshot.screenshotPath,\n );\n await refreshBuild(build.id);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,MAAa,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;;AAE7F,MAAa,kBAA0C;CAAC;CAAa;CAAY;AAAO;;AAExF,MAAa,iBAAyC,CAAC,YAAY,OAAO;;AAG1E,MAAa,wBAAwB,EAClC,OAAO;CACN,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CACxB,WAAW,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CAC3B,aAAa,EAAE,OAAO,CAAC,CAAC,SAAS;CACjC,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;CAChC,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,QAAQ,EAAE,MAAM,EAAE,OAAO;EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;EAAG,OAAO,EAAE,OAAO;CAAE,CAAC,CAAC,CAAC,CAAC,SAAS;AACpF,CAAC,CAAC,CACD,QAAQ,aAAa;AAaxB,eAAe,aACb,IACA,WACA,SACA,QACe;CACf,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,KAAK,MAAM,EAAE,KAAK,WAAW,QAAQ;EAEnC,IAAI,CAAC,MADkB,OAAO,QAAQ,WAAW,GAAG,GAElD,MAAM,OAAO,WAAW,WAAW;GAAE;GAAK,MAAM;EAAI,CAAC;EAEvD,MAAM,OAAO,OAAO,WAAW,SAAS,KAAK,KAAK;CACpD;AACF;;;;;AAOA,eAAsB,kBACpB,SACA,MAC6C;CAC7C,MAAM,EAAE,IAAI,WAAW,SAAS;CAChC,IAAI,CAAC,KAAK,UAAU,CAAC,KAAK,WACxB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,oCAAoC,CAAC;CAE/E,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI;EACxD,QAAQ,KAAK;EACb,WAAW,KAAK;EAChB,WAAW,KAAK,cAAc,QAAQ;EACtC,aAAa,KAAK;EAClB,YAAY,KAAK;EACjB,SAAS,KAAK;CAChB,CAAC;CACD,IAAI,KAAK,UAAU,KAAK,OAAO,SAAS,GACtC,MAAM,aAAa,IAAI,QAAQ,IAAI,MAAM,IAAI,KAAK,MAAM;CAE1D,MAAM,iBACJ,IACA,QAAQ,IACR,iBACA;EACE,SAAS,MAAM;EACf,QAAQ,KAAK;EACb,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,YAAY,KAAK;EACjB,SAAS,KAAK;CAChB,GACA,OAAO,MACT;CACA,OAAO;AACT;;;;;AAMA,eAAsB,kBACpB,SACA,MACA,MACA,UACe;CACf,IAAI,CAAC,MACH,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,uBAAuB,CAAC;CAElE,IAAI,OAAO;CACX,MAAM,UAAU,IAAI,UAAU,EAC5B,UACE,OACA,WACA,UACM;EACN,QAAQ,MAAM;EACd,IAAI,OAAO,UACT,yBAAS,IAAI,MAAM,kBAAkB,SAAS,YAAY,CAAC;OAE3D,SAAS,MAAM,KAAK;CAExB,EACF,CAAC;CACD,IAAI;EACF,MAAM,WAAW,SAAS,QAAQ,IAAgC;EAClE,MAAM,QAAQ,YAAY,MAAM,SAAS,KAAK,OAAO,CAAC;CACxD,SAAS,OAAO;EACd,IAAI,OAAO,UACT,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,4BAA4B,CAAC;EAEvE,MAAM;CACR;AACF;;AAGA,SAAS,mBAAmB,OAA+C;CACzE,IAAI,CAAC,OACH;CAEF,MAAM,SAAS,KAAK,MAAM,OAAO,KAAK,CAAC;CACvC,OAAO,OAAO,cAAc,MAAM,KAAK,SAAS,IAAI,SAAS,KAAA;AAC/D;;;;;;AAOA,eAAsB,qBACpB,SACA,YACA,gBACA,eACA,WACA,SACA,QACe;CACf,IAAI,CAAC,cAAc,CAAC,gBAClB;CAEF,MAAM,OAAO,mBAAmB,aAAa;CAC7C,IAAI,SAAS,KAAA,KAAa,OAAO,gBAC/B;CAEF,IAAI;EACF,MAAM,eAAe,MAAM,0BAA0B,SAAS,YAAY,WAAW,OAAO;EAC5F,MAAM,wBAAwB,SAAS,cAAc,WAAW,OAAO;EACvE,OAAO,KAAK;GAAE;GAAS;EAAK,GAAG,iCAAiC;CAClE,SAAS,OAAO;EACd,OAAO,MAAM;GAAE,KAAK;GAAO;EAAQ,GAAG,sDAAsD;CAC9F;AACF;;AAEA,MAAa,iBAAiB,EAAE,OAAO;CACrC,QAAQ,EAAE,KAAK,cAAc,CAAC,CAAC,SAAS;CACxC,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;CAC5B,UAAU,EAAE,OAAO,CAAC,CAAC,SAAS;CAC9B,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;AAClC,CAAC;;AAGD,eAAsB,gBACpB,WACA,SAC6C;CAC7C,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO;CAC7D,IAAI,CAAC,SAAS,MAAM,cAAc,WAChC,SAAS,iBAAiB;CAE5B,OAAO;AACT;;AAGA,eAAsB,iBACpB,OACA,YACmD;CACnD,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,MAAM,IAC1C,SAAS,oBAAoB;CAE/B,OAAO;AACT;;AAGA,eAAsB,aAAa,SAAgC;CACjE,MAAM,EAAE,IAAI,WAAW,SAAS;CAChC,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,aAAa,OAAO;CAC7C,MAAM,YAAY,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,YAAY,OAAO;CACjE,MAAM,aAAa,UAAU,MAAM,MAAM,EAAE,WAAW,SAAS,EAAE,WAAW,SAAS;CACrF,IAAI;CACJ,IAAI,YACF,SAAS;MAGT,SADiB,UAAU,MAAM,MAAM,EAAE,WAAW,UACpC,IAAI,aAAa;CAEnC,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,OAAO;CAClD,IAAI,OAAO;EACT,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,SAAS,MAAM;EAClD,MAAM,iBACJ,IACA,MAAM,WACN,SAAS,UACT;GACE;GACA;GACA,eAAe,UAAU;EAC3B,GACA,OAAO,MACT;CACF;AACF;;AAGA,eAAsB,gBAAgB,YAAoB,QAA+B;CACvF,MAAM,EAAE,IAAI,WAAW,SAAS;CAChC,MAAM,YAAY,IAAI,cAAc,EAAE;CACtC,MAAM,WAAW,MAAM,UAAU,IAAI,UAAU;CAC/C,IAAI,CAAC,UACH,SAAS,oBAAoB;CAE/B,MAAM,UAAU,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,IAAI,SAAS,SAAS;CACjE,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;CAC3D,IAAI,CAAC,WAAW,CAAC,OACf,SAAS,4BAA4B;CAEvC,MAAM,UAAU,OAAO,YAAY,YAAY,MAAM;CAErD,MAAM,IADgB,cAAc,IAAI,SAAS,CAAC,CAAC,SAAS,OAAO,MACrD,CAAC,CAAC,OACd,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,SAAS,IACT,SAAS,cACX;CACA,MAAM,aAAa,MAAM,EAAE;AAC7B"}
|
package/dist/routers/builds.mjs
CHANGED
|
@@ -2,14 +2,14 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { BuildModel } from "../models/build.mjs";
|
|
5
|
+
import { storybookZipPath } from "../paths.mjs";
|
|
6
|
+
import "../config.mjs";
|
|
5
7
|
import { getStore } from "../store.mjs";
|
|
6
8
|
import { resolveAuthorizedProject } from "./helpers.mjs";
|
|
7
9
|
import { badRequest, buildSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import { storybookZipPath } from "../utils/paths.mjs";
|
|
10
|
-
import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, asString, buildForProject, buildListQuery, buildUploadSchema } from "./builds.handlers.mjs";
|
|
11
|
-
import { registerSnapshots } from "./snapshots.mjs";
|
|
10
|
+
import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, storeUploadStream } from "./builds.handlers.mjs";
|
|
12
11
|
import { registerComments } from "./comments.mjs";
|
|
12
|
+
import { registerSnapshots } from "./snapshots.mjs";
|
|
13
13
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
14
14
|
import { HTTPException } from "hono/http-exception";
|
|
15
15
|
//#region src/routers/builds.ts
|
|
@@ -29,17 +29,48 @@ const listBuildsRoute = createRoute({
|
|
|
29
29
|
...unauthorized
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
|
+
const buildCreatedSchema = z.object({
|
|
33
|
+
build: buildSchema,
|
|
34
|
+
uploadUrl: z.string()
|
|
35
|
+
}).openapi("BuildCreated");
|
|
32
36
|
const createBuildRoute = createRoute({
|
|
33
37
|
method: "post",
|
|
34
38
|
path: "/api/v1/projects/{slug}/builds",
|
|
35
39
|
request: {
|
|
36
40
|
params: z.object({ slug: z.string() }),
|
|
37
|
-
body: { content: { "
|
|
41
|
+
body: { content: { "application/json": { schema: buildCreateJsonSchema } } }
|
|
42
|
+
},
|
|
43
|
+
responses: {
|
|
44
|
+
202: {
|
|
45
|
+
content: { "application/json": { schema: buildCreatedSchema } },
|
|
46
|
+
description: "Build created. PUT the Storybook zip to uploadUrl to queue capture."
|
|
47
|
+
},
|
|
48
|
+
...badRequest,
|
|
49
|
+
...forbidden,
|
|
50
|
+
...notFound
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const uploadZipRoute = createRoute({
|
|
54
|
+
method: "put",
|
|
55
|
+
path: "/api/v1/projects/{slug}/builds/{buildId}/zip",
|
|
56
|
+
request: {
|
|
57
|
+
params: z.object({
|
|
58
|
+
slug: z.string(),
|
|
59
|
+
buildId: z.string()
|
|
60
|
+
}),
|
|
61
|
+
body: { content: { "application/zip": { schema: z.string().openapi({
|
|
62
|
+
type: "string",
|
|
63
|
+
format: "binary"
|
|
64
|
+
}) } } }
|
|
38
65
|
},
|
|
39
66
|
responses: {
|
|
40
67
|
202: {
|
|
41
68
|
content: { "application/json": { schema: buildSchema } },
|
|
42
|
-
description: "
|
|
69
|
+
description: "Zip stored and capture queued"
|
|
70
|
+
},
|
|
71
|
+
413: {
|
|
72
|
+
content: { "application/json": { schema: z.object({ message: z.string() }).openapi("Error") } },
|
|
73
|
+
description: "Upload exceeds the configured size limit"
|
|
43
74
|
},
|
|
44
75
|
...badRequest,
|
|
45
76
|
...forbidden,
|
|
@@ -104,36 +135,25 @@ function registerBuilds(app) {
|
|
|
104
135
|
});
|
|
105
136
|
app.openapi(createBuildRoute, async (c) => {
|
|
106
137
|
const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...DEVELOPER_ROLES);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
await emitWebhookEvent(getStore().db, project.id, "build:created", {
|
|
123
|
-
buildId: build.id,
|
|
124
|
-
gitSha,
|
|
125
|
-
gitBranch,
|
|
126
|
-
authorEmail,
|
|
127
|
-
authorName,
|
|
128
|
-
message
|
|
129
|
-
});
|
|
130
|
-
const zip = form.get("zip");
|
|
131
|
-
if (zip && typeof zip !== "string") {
|
|
132
|
-
const buffer = Buffer.from(await zip.arrayBuffer());
|
|
133
|
-
await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);
|
|
134
|
-
}
|
|
138
|
+
const parsed = buildCreateJsonSchema.safeParse(await c.req.json());
|
|
139
|
+
if (!parsed.success) throw new HTTPException(400, { message: parsed.error.message });
|
|
140
|
+
const build = await createBuildRecord(project, parsed.data);
|
|
141
|
+
const uploadUrl = `/api/v1/projects/${project.slug}/builds/${build.id}/zip`;
|
|
142
|
+
return c.json({
|
|
143
|
+
build,
|
|
144
|
+
uploadUrl
|
|
145
|
+
}, 202);
|
|
146
|
+
});
|
|
147
|
+
app.openapi(uploadZipRoute, async (c) => {
|
|
148
|
+
const { slug, buildId } = c.req.valid("param");
|
|
149
|
+
const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
|
|
150
|
+
const build = await buildForProject(project.id, buildId);
|
|
151
|
+
const maxBytes = getStore().config.maxUploadBytes ?? 1073741824;
|
|
152
|
+
await storeUploadStream(getStore().storage, storybookZipPath(project.id, build.id), c.req.raw.body, maxBytes);
|
|
135
153
|
const reqId = c.get("requestId");
|
|
136
154
|
await getStore().enqueueCapture?.(build.id, reqId);
|
|
155
|
+
const { storage, config, logger } = getStore();
|
|
156
|
+
await persistInlineStatics(storage, config.scratchDir, config.maxInlineUnzipSize, c.req.header("content-length"), project.id, build.id, logger);
|
|
137
157
|
return c.json(build, 202);
|
|
138
158
|
});
|
|
139
159
|
app.openapi(getBuildRoute, async (c) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { HTTPException } from \"hono/http-exception\";\n\nimport { BuildModel } from \"../models/build.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, buildSchema, forbidden as forbiddenResponse, notFound as notFoundResponse, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, APPROVER_ROLES, buildUploadSchema, buildListQuery, buildForProject, asString } from \"./builds.handlers.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\nimport { registerComments } from \"./comments.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema.array() } }, description: \"List builds for a project\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"multipart/form-data\": { schema: buildUploadSchema } } },\n },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build created and capture queued\" },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build reset to pending\" },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the build list, upload, fetch, retry, and delete endpoints. */\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch, labelKey, labelValue } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, { status, branch: branch ?? undefined, labelKey: labelKey ?? undefined, labelValue: labelValue ?? undefined });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...DEVELOPER_ROLES);\n const form = await c.req.formData();\n\n const gitSha = asString(form.get(\"gitSha\")) ?? \"\";\n const gitBranch = asString(form.get(\"gitBranch\")) ?? \"\";\n if (!gitSha || !gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const authorEmail = asString(form.get(\"authorEmail\"));\n const authorName = asString(form.get(\"authorName\"));\n const message = asString(form.get(\"message\"));\n\n const build = await new BuildModel(getStore().db).create(project.id, {\n gitSha,\n gitBranch,\n isDefault: gitBranch === project.gitDefaultBranch,\n authorEmail,\n authorName,\n message,\n });\n\n await emitWebhookEvent(getStore().db, project.id, \"build:created\", {\n buildId: build.id,\n gitSha,\n gitBranch,\n authorEmail,\n authorName,\n message,\n });\n\n const zip = form.get(\"zip\");\n if (zip && typeof zip !== \"string\") {\n const buffer = Buffer.from(await zip.arrayBuffer());\n await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);\n }\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { DEFAULT_MAX_UPLOAD_BYTES } from \"../config.ts\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport {\n VIEW_ROLES,\n DEVELOPER_ROLES,\n APPROVER_ROLES,\n buildCreateJsonSchema,\n buildListQuery,\n buildForProject,\n createBuildRecord,\n persistInlineStatics,\n storeUploadStream,\n} from \"./builds.handlers.ts\";\nimport { registerComments } from \"./comments.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n badRequest,\n buildSchema,\n forbidden as forbiddenResponse,\n notFound as notFoundResponse,\n unauthorized,\n} from \"./schemas.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildSchema.array() } },\n description: \"List builds for a project\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst buildCreatedSchema = z\n .object({ build: buildSchema, uploadUrl: z.string() })\n .openapi(\"BuildCreated\");\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: {\n content: {\n \"application/json\": { schema: buildCreateJsonSchema },\n },\n },\n },\n responses: {\n 202: {\n content: {\n \"application/json\": {\n schema: buildCreatedSchema,\n },\n },\n description: \"Build created. PUT the Storybook zip to uploadUrl to queue capture.\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst uploadZipRoute = createRoute({\n method: \"put\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/zip\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: {\n content: {\n \"application/zip\": {\n schema: z.string().openapi({ type: \"string\", format: \"binary\" }),\n },\n },\n },\n },\n responses: {\n 202: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"Zip stored and capture queued\",\n },\n 413: {\n content: {\n \"application/json\": {\n schema: z.object({ message: z.string() }).openapi(\"Error\"),\n },\n },\n description: \"Upload exceeds the configured size limit\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"Build reset to pending\",\n },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the build list, upload, fetch, retry, and delete endpoints. */\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch, labelKey, labelValue } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, {\n status,\n branch: branch ?? undefined,\n labelKey: labelKey ?? undefined,\n labelValue: labelValue ?? undefined,\n });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(\n c,\n c.req.valid(\"param\").slug,\n ...DEVELOPER_ROLES,\n );\n const parsed = buildCreateJsonSchema.safeParse(await c.req.json());\n if (!parsed.success) {\n throw new HTTPException(400, { message: parsed.error.message });\n }\n const build = await createBuildRecord(project, parsed.data);\n const uploadUrl = `/api/v1/projects/${project.slug}/builds/${build.id}/zip`;\n return c.json({ build, uploadUrl }, 202);\n });\n\n app.openapi(uploadZipRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const maxBytes = getStore().config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;\n await storeUploadStream(\n getStore().storage,\n storybookZipPath(project.id, build.id),\n c.req.raw.body,\n maxBytes,\n );\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n const { storage, config, logger } = getStore();\n await persistInlineStatics(\n storage,\n config.scratchDir,\n config.maxInlineUnzipSize,\n c.req.header(\"content-length\"),\n project.id,\n build.id,\n logger,\n );\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\
|
|
1
|
+
{"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { CommentModel } from \"../models/comment.ts\";\nimport { getStore } from \"../store.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { resolveAuthorizedProject, notFound as throwNotFound } from \"./helpers.ts\";\nimport { commentSchema, commentCreateSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst listCommentsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: commentSchema.array() } },\n description: \"List comments on a build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: { content: { \"application/json\": { schema: commentCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: commentSchema } },\n description: \"Comment created\",\n },\n ...notFound,\n },\n});\n\nconst resolveCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments/{commentId}/resolve\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), commentId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: commentSchema } },\n description: \"Comment resolved\",\n },\n ...notFound,\n },\n});\n\n/** Register the build comment list, create, and resolve endpoints. */\nexport function registerComments(app: ShelfApp): void {\n app.openapi(listCommentsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n return c.json(await new CommentModel(getStore().db).listByBuild(build.id));\n });\n\n app.openapi(createCommentRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const body = c.req.valid(\"json\");\n const userId = getStore().user?.id ?? \"anonymous\";\n const comment = await new CommentModel(getStore().db).create(\n project.id,\n build.id,\n userId,\n body,\n );\n return c.json(comment, 201);\n });\n\n app.openapi(resolveCommentRoute, async (c) => {\n const { slug, buildId, commentId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const comment = await new CommentModel(getStore().db).resolve(commentId);\n if (comment.buildId !== build.id) {\n throwNotFound(\"Comment not found\");\n }\n return c.json(comment);\n });\n}\n"],"mappings":";;;;;;;;;;AAQA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,MAAM,EAAE,EAAE;GACjE,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,YAAY;CACrC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,oBAAoB,EAAE,EAAE;CAC3E;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GACzD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;AAED,MAAM,sBAAsB,YAAY;CACtC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;CAAE,CAAC,EAAE;CAC9F,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GACzD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,iBAAiB,KAAqB;CACpD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC;CAC3E,CAAC;CAED,IAAI,QAAQ,oBAAoB,OAAO,MAAM;EAC3C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,SAAS,SAAS,CAAC,CAAC,MAAM,MAAM;EACtC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OACpD,QAAQ,IACR,MAAM,IACN,QACA,IACF;EACA,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,qBAAqB,OAAO,MAAM;EAC5C,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,IAAI,MAAM,OAAO;EACxD,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,SAAS;EACvE,IAAI,QAAQ,YAAY,MAAM,IAC5B,SAAc,mBAAmB;EAEnC,OAAO,EAAE,KAAK,OAAO;CACvB,CAAC;AACH"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { requireSiteAdmin } from "./helpers.mjs";
|
|
5
|
+
//#region src/routers/health.ts
|
|
6
|
+
const PROBE_TIMEOUT_MS = 2e3;
|
|
7
|
+
const DETAIL_MAX_LENGTH = 500;
|
|
8
|
+
function pushTarget(targets, adapter) {
|
|
9
|
+
if (adapter) targets.push(adapter);
|
|
10
|
+
}
|
|
11
|
+
function targetsOf(sources) {
|
|
12
|
+
const targets = [];
|
|
13
|
+
pushTarget(targets, sources.database);
|
|
14
|
+
pushTarget(targets, sources.storage);
|
|
15
|
+
pushTarget(targets, sources.captureRunner);
|
|
16
|
+
pushTarget(targets, sources.captureQueue);
|
|
17
|
+
pushTarget(targets, sources.auth);
|
|
18
|
+
for (const provider of sources.gitHosts ?? []) pushTarget(targets, provider);
|
|
19
|
+
return targets;
|
|
20
|
+
}
|
|
21
|
+
function sanitize(detail) {
|
|
22
|
+
return detail.length > DETAIL_MAX_LENGTH ? detail.slice(0, DETAIL_MAX_LENGTH) : detail;
|
|
23
|
+
}
|
|
24
|
+
function messageOf(reason) {
|
|
25
|
+
return sanitize(reason instanceof Error ? reason.message : String(reason));
|
|
26
|
+
}
|
|
27
|
+
async function timeoutError() {
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
setTimeout(resolve, PROBE_TIMEOUT_MS);
|
|
30
|
+
});
|
|
31
|
+
throw new Error("health probe timed out");
|
|
32
|
+
}
|
|
33
|
+
function okReport(target, latencyMs, detail) {
|
|
34
|
+
const { category, kind, name } = target.metadata;
|
|
35
|
+
return {
|
|
36
|
+
category,
|
|
37
|
+
kind,
|
|
38
|
+
name,
|
|
39
|
+
state: "ok",
|
|
40
|
+
latencyMs,
|
|
41
|
+
...detail === void 0 ? {} : { detail }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function failedReport(target, latencyMs, detail) {
|
|
45
|
+
const { category, kind, name } = target.metadata;
|
|
46
|
+
return {
|
|
47
|
+
category,
|
|
48
|
+
kind,
|
|
49
|
+
name,
|
|
50
|
+
state: "failed",
|
|
51
|
+
latencyMs,
|
|
52
|
+
detail
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function detailOf(result, fallback) {
|
|
56
|
+
return result.detail === void 0 ? fallback : sanitize(result.detail);
|
|
57
|
+
}
|
|
58
|
+
async function raceProbe(target, probe, started) {
|
|
59
|
+
try {
|
|
60
|
+
const result = await Promise.race([probe(), timeoutError()]);
|
|
61
|
+
const latencyMs = Date.now() - started;
|
|
62
|
+
if (!result.ok) return failedReport(target, latencyMs, detailOf(result, "probe reported failure"));
|
|
63
|
+
return okReport(target, latencyMs, result.detail === void 0 ? void 0 : sanitize(result.detail));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return failedReport(target, Date.now() - started, messageOf(error));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function probeHealth(target) {
|
|
69
|
+
const probe = target.lifecycle?.health;
|
|
70
|
+
if (!probe) {
|
|
71
|
+
const { category, kind, name } = target.metadata;
|
|
72
|
+
return {
|
|
73
|
+
category,
|
|
74
|
+
kind,
|
|
75
|
+
name,
|
|
76
|
+
state: "ok"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return await raceProbe(target, probe, Date.now());
|
|
80
|
+
}
|
|
81
|
+
async function probeTarget(target, initErrors, starting) {
|
|
82
|
+
const { category, kind, name } = target.metadata;
|
|
83
|
+
const initError = initErrors.get(`${category}/${kind}`);
|
|
84
|
+
if (initError !== void 0) return {
|
|
85
|
+
category,
|
|
86
|
+
kind,
|
|
87
|
+
name,
|
|
88
|
+
state: "failed",
|
|
89
|
+
detail: sanitize(initError)
|
|
90
|
+
};
|
|
91
|
+
if (starting) return {
|
|
92
|
+
category,
|
|
93
|
+
kind,
|
|
94
|
+
name,
|
|
95
|
+
state: "starting"
|
|
96
|
+
};
|
|
97
|
+
return await probeHealth(target);
|
|
98
|
+
}
|
|
99
|
+
function collectInitErrors(settled) {
|
|
100
|
+
const initErrors = /* @__PURE__ */ new Map();
|
|
101
|
+
if (settled) for (const failure of settled.failures) initErrors.set(`${failure.category}/${failure.kind}`, failure.error);
|
|
102
|
+
return initErrors;
|
|
103
|
+
}
|
|
104
|
+
function overallStatus(reports) {
|
|
105
|
+
if (reports.some((report) => report.state === "failed")) return "degraded";
|
|
106
|
+
if (reports.some((report) => report.state === "starting")) return "starting";
|
|
107
|
+
return "ok";
|
|
108
|
+
}
|
|
109
|
+
/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */
|
|
110
|
+
function registerHealth(app, deps) {
|
|
111
|
+
app.get("/api/v1/health", (c) => {
|
|
112
|
+
const body = {
|
|
113
|
+
status: "ok",
|
|
114
|
+
uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1e3),
|
|
115
|
+
version: deps.version
|
|
116
|
+
};
|
|
117
|
+
c.header("Cache-Control", "no-store");
|
|
118
|
+
return c.json(body);
|
|
119
|
+
});
|
|
120
|
+
app.post("/api/v1/health", async (c) => {
|
|
121
|
+
requireSiteAdmin();
|
|
122
|
+
const settled = deps.getSettled();
|
|
123
|
+
const initErrors = collectInitErrors(settled);
|
|
124
|
+
const targets = targetsOf(deps.sources);
|
|
125
|
+
const reports = await Promise.all(targets.map(async (target) => await probeTarget(target, initErrors, settled === null)));
|
|
126
|
+
const body = {
|
|
127
|
+
status: overallStatus(reports),
|
|
128
|
+
adapters: reports
|
|
129
|
+
};
|
|
130
|
+
c.header("Cache-Control", "no-store");
|
|
131
|
+
return c.json(body, body.status === "ok" ? 200 : 503);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
export { registerHealth };
|
|
136
|
+
|
|
137
|
+
//# sourceMappingURL=health.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter init — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the init settlement state instead of being masked by the gate.\n */\nimport type { AdapterInitResult, AdapterInitSources } from \"../adapters/init.ts\";\nimport type { AdapterHealth, AdapterLifecycle, AdapterMetadata } from \"../adapters/metadata.ts\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterInitSources;\n getSettled: () => AdapterInitResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n}\n\ntype AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterInitSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushTarget(targets, sources.database);\n pushTarget(targets, sources.storage);\n pushTarget(targets, sources.captureRunner);\n pushTarget(targets, sources.captureQueue);\n pushTarget(targets, sources.auth);\n for (const provider of sources.gitHosts ?? []) {\n pushTarget(targets, provider);\n }\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return detail.length > DETAIL_MAX_LENGTH ? detail.slice(0, DETAIL_MAX_LENGTH) : detail;\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReport {\n const { category, kind, name } = target.metadata;\n return {\n category,\n kind,\n name,\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReport {\n const { category, kind, name } = target.metadata;\n return { category, kind, name, state: \"failed\", latencyMs, detail };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReport> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReport> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n const { category, kind, name } = target.metadata;\n return { category, kind, name, state: \"ok\" };\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n initErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReport> {\n const { category, kind, name } = target.metadata;\n const initError = initErrors.get(`${category}/${kind}`);\n if (initError !== undefined) {\n return { category, kind, name, state: \"failed\", detail: sanitize(initError) };\n }\n if (starting) {\n return { category, kind, name, state: \"starting\" };\n }\n return await probeHealth(target);\n}\n\nfunction collectInitErrors(settled: AdapterInitResult | null): ReadonlyMap<string, string> {\n const initErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n initErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return initErrors;\n}\n\nfunction overallStatus(reports: AdapterReport[]): ReadinessBody[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfApp, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin();\n const settled = deps.getSettled();\n const initErrors = collectInitErrors(settled);\n const targets = targetsOf(deps.sources);\n const reports = await Promise.all(\n targets.map(async (target) => await probeTarget(target, initErrors, settled === null)),\n );\n const body: ReadinessBody = { status: overallStatus(reports), adapters: reports };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":"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"}
|
package/dist/routers/helpers.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import { projects } from "../schema
|
|
4
|
+
import { projects } from "../schema/project.mjs";
|
|
5
5
|
import { ProjectModel } from "../models/project.mjs";
|
|
6
|
+
import { sha256 } from "../utils/hash.mjs";
|
|
6
7
|
import { getStore } from "../store.mjs";
|
|
7
8
|
import { MemberModel } from "../models/member.mjs";
|
|
8
9
|
import { TokenModel } from "../models/token.mjs";
|
|
9
|
-
import { sha256 } from "../utils/hash.mjs";
|
|
10
10
|
import { eq } from "drizzle-orm";
|
|
11
11
|
import { HTTPException } from "hono/http-exception";
|
|
12
12
|
//#region src/routers/helpers.ts
|