Skip to content

Commit 0a03e58

Browse files
committed
docs(changeset): Add purge functionality
1 parent 09a4679 commit 0a03e58

File tree

12 files changed

+221
-21
lines changed

12 files changed

+221
-21
lines changed

.changeset/strong-lions-beg.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@storybooker/azure-functions": minor
3+
"@storybooker/core": minor
4+
---
5+
6+
Add purge functionality

packages/azure-functions/src/index.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { app } from "@azure/functions";
2-
import { createRequestHandler } from "@storybooker/core";
2+
import { createPurgeHandler, createRequestHandler } from "@storybooker/core";
33
import { SERVICE_NAME } from "@storybooker/core/constants";
44
import type {
55
RequestHandlerOptions,
@@ -12,7 +12,7 @@ import {
1212
transformWebResponseToHttpResponse,
1313
} from "./utils";
1414

15-
// const DEFAULT_PURGE_SCHEDULE_CRON = "0 0 0 * * *";
15+
const DEFAULT_PURGE_SCHEDULE_CRON = "0 0 0 * * *";
1616

1717
export type * from "@storybooker/core/types";
1818

@@ -48,7 +48,7 @@ export interface RegisterStorybookerRouterOptions<User extends StoryBookerUser>
4848
*
4949
* @default "0 0 0 * * *" // Every midnight
5050
*/
51-
// purgeScheduleCron?: string | null;
51+
purgeScheduleCron?: string | null;
5252
}
5353

5454
export function registerStoryBookerRouter<User extends StoryBookerUser>(
@@ -77,11 +77,21 @@ export function registerStoryBookerRouter<User extends StoryBookerUser>(
7777
route: urlJoin(route, "{**path}"),
7878
});
7979

80-
// if (options.purgeScheduleCron !== null) {
81-
// app.timer(`${SERVICE_NAME}-timer_purge`, {
82-
// handler: wrapTimerHandlerWithStore(routerOptions, timerPurgeHandler),
83-
// runOnStartup: false,
84-
// schedule: options.purgeScheduleCron || DEFAULT_PURGE_SCHEDULE_CRON,
85-
// });
86-
// }
80+
if (options.purgeScheduleCron !== null) {
81+
const schedule = options.purgeScheduleCron || DEFAULT_PURGE_SCHEDULE_CRON;
82+
const purgeHandler = createPurgeHandler({
83+
database: options.database,
84+
errorParser: options.errorParser ?? parseAzureRestError,
85+
logger,
86+
storage: options.storage,
87+
});
88+
89+
logger.log("Registering Storybooker Timer-Purge (cron: %s)", schedule);
90+
app.timer(`${SERVICE_NAME}-timer_purge`, {
91+
// oxlint-disable-next-line require-await
92+
handler: async (_timer, context) => purgeHandler({}, { logger: context }),
93+
runOnStartup: false,
94+
schedule,
95+
});
96+
}
8797
}

packages/core/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,19 @@ const options: RequestHandlerOptions = {
4343

4444
export default { fetch: createRequestHandler(options) };
4545
```
46+
47+
## Exports
48+
49+
### `createRequestHandler`
50+
51+
Callback to create a request-handler based on provided options.
52+
53+
The request handler takes Standard Request and returns a Response asynchronously.
54+
55+
### `createPurgeHandler`
56+
57+
Callback to create a purge-handler based on provided options.
58+
59+
Purging deletes all builds older than certain days based on Project's configuration.
60+
61+
Note: The latest build on project's default branch is not deleted.

packages/core/src/builds/model.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ export class BuildsModel extends Model<BuildType> {
165165
labelSlugs.map(async (labelSlug) => {
166166
const label = await labelsModel.get(labelSlug);
167167
if (label.latestBuildSHA === buildId) {
168-
await labelsModel.update(labelSlug, { latestBuildSHA: undefined });
168+
await labelsModel.update(labelSlug, {
169+
buildsCount: Math.max(label.buildsCount - 1, 0),
170+
latestBuildSHA: undefined,
171+
});
169172
}
170173
}),
171174
);
@@ -265,18 +268,25 @@ export class BuildsModel extends Model<BuildType> {
265268
.map((part) => part.trim());
266269

267270
try {
268-
await labelsModel.update(slug, { latestBuildSHA: buildSHA });
271+
const existingLabel = await labelsModel.get(labelSlug);
272+
await labelsModel.update(slug, {
273+
buildsCount: existingLabel.buildsCount + 1,
274+
latestBuildSHA: buildSHA,
275+
});
269276
return slug;
270277
} catch {
271278
try {
272279
const type = labelType || LabelsModel.guessType(slug);
273280
const value = labelValue || slug;
274281
this.log("A new label '%s' (%s) is being created.", value, type);
275-
const label = await labelsModel.create({
276-
latestBuildSHA: buildSHA,
277-
type,
278-
value,
279-
});
282+
const label = await labelsModel.create(
283+
{
284+
latestBuildSHA: buildSHA,
285+
type,
286+
value,
287+
},
288+
true,
289+
);
280290

281291
return label.id;
282292
} catch (error) {

packages/core/src/builds/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export const deleteBuild = defineRoute(
235235
projectId,
236236
resource: "build",
237237
});
238-
await new BuildsModel(projectId).delete(buildSHA);
238+
await new BuildsModel(projectId).delete(buildSHA, true);
239239

240240
if (checkIsHTMLRequest() || checkIsHXRequest()) {
241241
return responseRedirect(urlBuilder.allBuilds(projectId), 303);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { BuildsModel } from "#builds/model";
2+
import { DEFAULT_PURGE_AFTER_DAYS, ONE_DAY_IN_MS } from "#constants";
3+
import { LabelsModel } from "#labels/model";
4+
import { ProjectsModel } from "#projects/model";
5+
import type { ProjectType } from "#projects/schema";
6+
import { getStore } from "#store";
7+
import type { LoggerService } from "../types";
8+
9+
export type HandlePurge = (
10+
params: { projectId?: string },
11+
options: { abortSignal?: AbortSignal; logger?: LoggerService },
12+
) => Promise<void>;
13+
14+
export const handlePurge: HandlePurge = async ({ projectId }) => {
15+
const projectModel = new ProjectsModel();
16+
if (projectId) {
17+
const project = await projectModel.get(projectId);
18+
await purgeProject(project);
19+
} else {
20+
const projects = await projectModel.list();
21+
const promises = projects.map((project) => purgeProject(project));
22+
await Promise.allSettled(promises);
23+
}
24+
};
25+
26+
async function purgeProject(project: ProjectType): Promise<void> {
27+
const { locale, logger } = getStore();
28+
const {
29+
id: projectId,
30+
gitHubDefaultBranch,
31+
latestBuildSHA,
32+
purgeBuildsAfterDays = DEFAULT_PURGE_AFTER_DAYS,
33+
} = project;
34+
const expiryTime = new Date(
35+
Date.now() - purgeBuildsAfterDays * ONE_DAY_IN_MS,
36+
);
37+
logger.log(
38+
`[Project: ${projectId}] Purge builds which were last modified more than ${purgeBuildsAfterDays} days ago - since ${new Date(
39+
expiryTime,
40+
).toLocaleString(locale)}`,
41+
);
42+
43+
const buildsModel = new BuildsModel(projectId);
44+
const expiredBuilds = await buildsModel.list({
45+
filter: (item) =>
46+
item.id !== latestBuildSHA && new Date(item.updatedAt) < expiryTime,
47+
});
48+
for (const build of expiredBuilds) {
49+
// oxlint-disable-next-line no-await-in-loop
50+
await buildsModel.delete(build.id, true);
51+
}
52+
logger.log(
53+
`[Project: ${projectId}] Purged ${expiredBuilds.length} expired builds.`,
54+
);
55+
56+
const labelsModel = new LabelsModel(projectId);
57+
const emptyLabels = await labelsModel.list({
58+
filter: (item) => {
59+
if (item.type === "branch" && item.value === gitHubDefaultBranch) {
60+
return false;
61+
}
62+
return item.buildsCount === 0;
63+
},
64+
});
65+
for (const label of emptyLabels) {
66+
// oxlint-disable-next-line no-await-in-loop
67+
await labelsModel.delete(label.id);
68+
}
69+
logger.log(
70+
`[Project: ${projectId}] Purged ${emptyLabels.length} empty labels...`,
71+
);
72+
}

packages/core/src/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { DEFAULT_LOCALE, HEADERS } from "#constants";
44
import { localStore } from "#store";
55
import { parseErrorMessage } from "#utils/error";
66
import { createMiddlewaresPipelineRequestHandler } from "#utils/middleware-utils";
7+
import { handlePurge, type HandlePurge } from "./handlers/handle-purge";
78
import { handleStaticFileRoute } from "./handlers/handle-static-file-route";
89
import { router } from "./router";
910
import { translations_enGB } from "./translations/en-gb";
1011
import type {
1112
AuthService,
13+
PurgeHandlerOptions,
1214
RequestHandler,
1315
RequestHandlerOptions,
1416
StoryBookerUser,
@@ -32,7 +34,6 @@ export function createRequestHandler<User extends StoryBookerUser>(
3234
options: RequestHandlerOptions<User>,
3335
): RequestHandler {
3436
const logger = options.logger || console;
35-
3637
const initPromises = Promise.allSettled([
3738
options.auth?.init?.({}).catch(logger.error),
3839
options.database.init?.({}).catch(logger.error),
@@ -89,3 +90,42 @@ export function createRequestHandler<User extends StoryBookerUser>(
8990

9091
return requestHandler;
9192
}
93+
94+
/**
95+
* Callback to create a purge-handler based on provided options.
96+
* Purging deletes all builds older than certain days based on Project's configuration.
97+
*
98+
* Note: The latest build on project's default branch is not deleted.
99+
*/
100+
export function createPurgeHandler(options: PurgeHandlerOptions): HandlePurge {
101+
const logger = options.logger || console;
102+
const initPromises = Promise.allSettled([
103+
options.database.init?.({}).catch(logger.error),
104+
options.storage.init?.({}).catch(logger.error),
105+
]);
106+
107+
return async (...params: Parameters<HandlePurge>) => {
108+
// Make sure initialisations are complete before first request is handled.
109+
await initPromises;
110+
111+
try {
112+
localStore.enterWith({
113+
abortSignal: params[1].abortSignal,
114+
database: options.database,
115+
errorParser: options.errorParser,
116+
locale: DEFAULT_LOCALE,
117+
logger: params[1]?.logger ?? logger,
118+
prefix: "/",
119+
request: new Request(""),
120+
storage: options.storage,
121+
translation: translations_enGB,
122+
url: "/",
123+
user: null,
124+
});
125+
126+
await handlePurge(...params);
127+
} catch (error) {
128+
logger.error(parseErrorMessage(error, options.errorParser).errorMessage);
129+
}
130+
};
131+
}

packages/core/src/labels/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ export class LabelsModel extends Model<LabelType> {
3030
return LabelSchema.array().parse(items);
3131
}
3232

33-
async create(data: unknown): Promise<LabelType> {
33+
async create(data: unknown, withBuild = false): Promise<LabelType> {
3434
const parsedData = LabelCreateSchema.parse(data);
3535
this.log("Create label '%s'...", parsedData.value);
3636

3737
const slug = LabelsModel.createSlug(parsedData.value);
3838
const now = new Date().toISOString();
3939
const label: LabelType = {
4040
...parsedData,
41+
buildsCount: withBuild ? 1 : 0,
4142
createdAt: now,
4243
id: slug,
4344
slug,

packages/core/src/labels/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type LabelType = z.infer<typeof LabelSchema>;
77
/** @private */
88
export const LabelSchema = z
99
.object({
10+
buildsCount: z.number().default(0),
1011
createdAt: z.iso.datetime().default(new Date().toISOString()),
1112
id: LabelSlugSchema,
1213
latestBuildSHA: z.union([BuildSHASchema.optional(), z.literal("")]),
@@ -19,6 +20,7 @@ export const LabelSchema = z
1920

2021
export type LabelCreateType = z.infer<typeof LabelCreateSchema>;
2122
export const LabelCreateSchema = LabelSchema.omit({
23+
buildsCount: true,
2224
createdAt: true,
2325
id: true,
2426
slug: true,

packages/core/src/root/routes.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { URLS } from "#urls";
66
import { authenticateOrThrow } from "#utils/auth";
77
import { checkIsJSONRequest } from "#utils/request";
88
import { commonErrorResponses, responseHTML } from "#utils/response";
9-
import { urlJoin } from "#utils/url";
9+
import { urlJoin, urlSearchParamsToObject } from "#utils/url";
1010
import z from "zod";
1111
import { handleOpenAPIRoute } from "../handlers/handle-openapi-route";
12+
import { handlePurge } from "../handlers/handle-purge";
1213
import { handleServeStoryBook } from "../handlers/handle-serve-storybook";
1314
import { renderRootPage } from "./render";
1415

@@ -125,3 +126,30 @@ export const openapi = defineRoute(
125126
},
126127
handleOpenAPIRoute,
127128
);
129+
130+
const purgeSearchParams = z.object({ project: z.string().optional() }).loose();
131+
export const purge = defineRoute(
132+
"post",
133+
URLS.ui.purge,
134+
{
135+
requestParams: { query: purgeSearchParams },
136+
responses: { 204: { description: "Purge complete" } },
137+
summary: "Purge old data",
138+
},
139+
async ({ request }) => {
140+
const { searchParams } = new URL(request.url);
141+
const { project: projectId } = purgeSearchParams.parse(
142+
urlSearchParamsToObject(searchParams),
143+
);
144+
145+
await authenticateOrThrow({
146+
action: "delete",
147+
projectId,
148+
resource: "project",
149+
});
150+
151+
await handlePurge({ projectId }, {});
152+
153+
return new Response(null, { status: 204 });
154+
},
155+
);

0 commit comments

Comments
 (0)