@michaelthielemann/kestrel-images-default 5.0.0

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.
@@ -0,0 +1,299 @@
1
+ import { z } from "zod";
2
+ import { type Context } from "@michaelthielemann/kestrel/context";
3
+ import { type Variant } from "./impl.ts";
4
+ export declare const configSchema: z.ZodObject<{
5
+ sizes: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodObject<{
6
+ name: z.ZodString;
7
+ width: z.ZodNumber;
8
+ height: z.ZodOptional<z.ZodNumber>;
9
+ fit: z.ZodDefault<z.ZodEnum<["inside", "cover"]>>;
10
+ format: z.ZodDefault<z.ZodEnum<["webp", "original"]>>;
11
+ quality: z.ZodDefault<z.ZodNumber>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ name: string;
14
+ width: number;
15
+ fit: "inside" | "cover";
16
+ format: "webp" | "original";
17
+ quality: number;
18
+ height?: number | undefined;
19
+ }, {
20
+ name: string;
21
+ width: number;
22
+ height?: number | undefined;
23
+ fit?: "inside" | "cover" | undefined;
24
+ format?: "webp" | "original" | undefined;
25
+ quality?: number | undefined;
26
+ }>, {
27
+ name: string;
28
+ width: number;
29
+ fit: "inside" | "cover";
30
+ format: "webp" | "original";
31
+ quality: number;
32
+ height?: number | undefined;
33
+ }, {
34
+ name: string;
35
+ width: number;
36
+ height?: number | undefined;
37
+ fit?: "inside" | "cover" | undefined;
38
+ format?: "webp" | "original" | undefined;
39
+ quality?: number | undefined;
40
+ }>, "many">>;
41
+ prefix: z.ZodDefault<z.ZodString>;
42
+ publicPath: z.ZodDefault<z.ZodString>;
43
+ media: z.ZodDefault<z.ZodObject<{
44
+ collection: z.ZodString;
45
+ }, "strip", z.ZodTypeAny, {
46
+ collection: string;
47
+ }, {
48
+ collection: string;
49
+ }>>;
50
+ chunk: z.ZodDefault<z.ZodNumber>;
51
+ staleAfterMs: z.ZodDefault<z.ZodNumber>;
52
+ maxAttempts: z.ZodDefault<z.ZodNumber>;
53
+ }, "strict", z.ZodTypeAny, {
54
+ prefix: string;
55
+ publicPath: string;
56
+ media: {
57
+ collection: string;
58
+ };
59
+ chunk: number;
60
+ staleAfterMs: number;
61
+ maxAttempts: number;
62
+ sizes?: {
63
+ name: string;
64
+ width: number;
65
+ fit: "inside" | "cover";
66
+ format: "webp" | "original";
67
+ quality: number;
68
+ height?: number | undefined;
69
+ }[] | undefined;
70
+ }, {
71
+ sizes?: {
72
+ name: string;
73
+ width: number;
74
+ height?: number | undefined;
75
+ fit?: "inside" | "cover" | undefined;
76
+ format?: "webp" | "original" | undefined;
77
+ quality?: number | undefined;
78
+ }[] | undefined;
79
+ prefix?: string | undefined;
80
+ publicPath?: string | undefined;
81
+ media?: {
82
+ collection: string;
83
+ } | undefined;
84
+ chunk?: number | undefined;
85
+ staleAfterMs?: number | undefined;
86
+ maxAttempts?: number | undefined;
87
+ }>;
88
+ declare const _default: import("@michaelthielemann/kestrel/defineModule").ModuleDefinition<"images/default", {
89
+ register: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
90
+ result: import("./sizes.ts").SizeRow[];
91
+ runId: string;
92
+ requestId?: string;
93
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
94
+ payload: Record<string, unknown>;
95
+ params: Record<string, string>;
96
+ headers: Record<string, string>;
97
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
98
+ ip?: string;
99
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
100
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
101
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
102
+ }>>;
103
+ listSizes: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Ok<{
104
+ result: import("./sizes.ts").SizeRow[];
105
+ runId: string;
106
+ requestId?: string;
107
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
108
+ payload: Record<string, unknown>;
109
+ params: Record<string, string>;
110
+ headers: Record<string, string>;
111
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
112
+ ip?: string;
113
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
114
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
115
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
116
+ }>>;
117
+ generate: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
118
+ result: {
119
+ items: {
120
+ id: string;
121
+ variants: Variant[];
122
+ }[];
123
+ };
124
+ runId: string;
125
+ requestId?: string;
126
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
127
+ payload: Record<string, unknown>;
128
+ params: Record<string, string>;
129
+ headers: Record<string, string>;
130
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
131
+ ip?: string;
132
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
133
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
134
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
135
+ }> | import("@michaelthielemann/kestrel/result").Ok<{
136
+ result: {
137
+ id: string;
138
+ variants: Variant[];
139
+ };
140
+ runId: string;
141
+ requestId?: string;
142
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
143
+ payload: Record<string, unknown>;
144
+ params: Record<string, string>;
145
+ headers: Record<string, string>;
146
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
147
+ ip?: string;
148
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
149
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
150
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
151
+ }>>;
152
+ sync: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
153
+ result: import("./impl.ts").Job;
154
+ runId: string;
155
+ requestId?: string;
156
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
157
+ payload: Record<string, unknown>;
158
+ params: Record<string, string>;
159
+ headers: Record<string, string>;
160
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
161
+ ip?: string;
162
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
163
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
164
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
165
+ }>>;
166
+ resume: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
167
+ result: import("./impl.ts").Job | null;
168
+ runId: string;
169
+ requestId?: string;
170
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
171
+ payload: Record<string, unknown>;
172
+ params: Record<string, string>;
173
+ headers: Record<string, string>;
174
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
175
+ ip?: string;
176
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
177
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
178
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
179
+ }>>;
180
+ prune: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
181
+ result: {
182
+ sizes: number;
183
+ variants: number;
184
+ };
185
+ runId: string;
186
+ requestId?: string;
187
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
188
+ payload: Record<string, unknown>;
189
+ params: Record<string, string>;
190
+ headers: Record<string, string>;
191
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
192
+ ip?: string;
193
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
194
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
195
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
196
+ }>>;
197
+ readStatus: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
198
+ result: import("./impl.ts").ImagesStatus;
199
+ runId: string;
200
+ requestId?: string;
201
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
202
+ payload: Record<string, unknown>;
203
+ params: Record<string, string>;
204
+ headers: Record<string, string>;
205
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
206
+ ip?: string;
207
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
208
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
209
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
210
+ }>>;
211
+ remove: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<Context>>;
212
+ removeMany: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<Context>>;
213
+ attach: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
214
+ result: {
215
+ variants: {
216
+ size: string;
217
+ width: number;
218
+ height: number;
219
+ format: string;
220
+ bytes: number;
221
+ state: Variant["state"];
222
+ path: string;
223
+ }[];
224
+ id?: unknown;
225
+ items?: unknown;
226
+ };
227
+ runId: string;
228
+ requestId?: string;
229
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
230
+ payload: Record<string, unknown>;
231
+ params: Record<string, string>;
232
+ headers: Record<string, string>;
233
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
234
+ ip?: string;
235
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
236
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
237
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
238
+ }> | import("@michaelthielemann/kestrel/result").Ok<{
239
+ result: {
240
+ items: {
241
+ variants: {
242
+ size: string;
243
+ width: number;
244
+ height: number;
245
+ format: string;
246
+ bytes: number;
247
+ state: Variant["state"];
248
+ path: string;
249
+ }[];
250
+ }[];
251
+ id?: unknown;
252
+ };
253
+ runId: string;
254
+ requestId?: string;
255
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
256
+ payload: Record<string, unknown>;
257
+ params: Record<string, string>;
258
+ headers: Record<string, string>;
259
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
260
+ ip?: string;
261
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
262
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
263
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
264
+ }>>;
265
+ serve: (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
266
+ result: import("@michaelthielemann/kestrel/context").BinaryResult;
267
+ runId: string;
268
+ requestId?: string;
269
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
270
+ payload: Record<string, unknown>;
271
+ params: Record<string, string>;
272
+ headers: Record<string, string>;
273
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
274
+ ip?: string;
275
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
276
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
277
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
278
+ }>>;
279
+ export: (dir: string) => (ctx: Context) => Promise<import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError<string>> | import("@michaelthielemann/kestrel/result").Ok<{
280
+ result: {
281
+ variants: {
282
+ written: number;
283
+ skipped: number;
284
+ };
285
+ };
286
+ runId: string;
287
+ requestId?: string;
288
+ trigger: import("@michaelthielemann/kestrel/context").Trigger;
289
+ payload: Record<string, unknown>;
290
+ params: Record<string, string>;
291
+ headers: Record<string, string>;
292
+ files: import("@michaelthielemann/kestrel/context").UploadedFile[];
293
+ ip?: string;
294
+ fail(code: import("@michaelthielemann/kestrel/errors").CoreCode, message: string, details?: Record<string, unknown>): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
295
+ fail(error: import("@michaelthielemann/kestrel/errors").KestrelError): import("@michaelthielemann/kestrel/result").Err<import("@michaelthielemann/kestrel/errors").KestrelError>;
296
+ done(result: unknown): import("@michaelthielemann/kestrel/result").Ok<Context>;
297
+ }>>;
298
+ }>;
299
+ export default _default;
package/dist/module.js ADDED
@@ -0,0 +1,208 @@
1
+ import { z } from "zod";
2
+ import { BLOBSTORE } from "@michaelthielemann/kestrel-contracts/blobstore";
3
+ import { PERSISTENCE } from "@michaelthielemann/kestrel-contracts/persistence";
4
+ import { binaryResult, first, stepFactory } from "@michaelthielemann/kestrel/context";
5
+ import { defineModule } from "@michaelthielemann/kestrel/defineModule";
6
+ import { isErr, ok } from "@michaelthielemann/kestrel/result";
7
+ import { DEFAULT_MAX_ATTEMPTS, createImages } from "./impl.js";
8
+ import { sizeSchema } from "./sizes.js";
9
+ export const configSchema = z
10
+ .object({
11
+ sizes: z.array(sizeSchema).optional(),
12
+ prefix: z.string().regex(/^([A-Za-z0-9._-]+\/)+$/, 'prefix must be one or more "<segment>/" parts, e.g. "media-variants/"').default("media-variants/"),
13
+ publicPath: z.string().regex(/^\/[A-Za-z0-9._/-]*$/).default("/media"),
14
+ media: z.object({ collection: z.string().min(1) }).default({ collection: "media_items" }),
15
+ chunk: z.number().int().min(1).max(500).default(20),
16
+ staleAfterMs: z.number().int().positive().default(60000),
17
+ maxAttempts: z.number().int().positive().default(DEFAULT_MAX_ATTEMPTS),
18
+ })
19
+ .strict();
20
+ function mediaId(ctx) {
21
+ if (ctx.params.id)
22
+ return ctx.params.id;
23
+ return first(ctx.payload.id);
24
+ }
25
+ function attachedVariant(images, variant) {
26
+ return { size: variant.size, width: variant.width, height: variant.height, format: variant.format, bytes: variant.bytes, state: variant.state, path: images.publicPath(variant.mediaId, variant) };
27
+ }
28
+ const SIZE_SCHEMA = { type: "object", properties: { name: { type: "string" }, width: { type: "number" }, height: { type: ["number", "null"] }, fit: { type: "string", enum: ["inside", "cover"] }, format: { type: "string", enum: ["webp", "original"] }, quality: { type: "number" }, source: { type: "string", enum: ["default", "config", "registered"] }, updatedAt: { type: "number" } }, required: ["name", "width", "fit", "format", "quality", "source", "updatedAt"] };
29
+ const SIZE_INPUT_SCHEMA = { type: "object", properties: { name: { type: "string" }, width: { type: "integer", minimum: 16, maximum: 8192 }, height: { type: "integer", minimum: 16, maximum: 8192 }, fit: { type: "string", enum: ["inside", "cover"] }, format: { type: "string", enum: ["webp", "original"] }, quality: { type: "integer", minimum: 1, maximum: 100 } }, required: ["name", "width"] };
30
+ const VARIANT_SCHEMA = { type: "object", properties: { id: { type: "string" }, mediaId: { type: "string" }, size: { type: "string" }, spec: { type: "string" }, width: { type: "number" }, height: { type: "number" }, format: { type: "string" }, key: { type: "string" }, bytes: { type: "number" }, state: { type: "string", enum: ["pending", "done", "error", "failed"] }, error: { type: ["string", "null"] }, attempts: { type: "number" }, updatedAt: { type: "number" } }, required: ["id", "mediaId", "size", "spec", "width", "height", "format", "key", "bytes", "state", "error", "attempts", "updatedAt"] };
31
+ const JOB_SCHEMA = { type: "object", properties: { id: { type: "string" }, state: { type: "string", enum: ["running", "paused", "done", "error"] }, total: { type: "number" }, done: { type: "number" }, failed: { type: "number" }, cursor: { type: "string" }, startedAt: { type: "number" }, updatedAt: { type: "number" }, finishedAt: { type: ["number", "null"] }, error: { type: ["string", "null"] } }, required: ["id", "state", "total", "done", "failed", "cursor", "startedAt", "updatedAt", "finishedAt", "error"] };
32
+ const ATTACHED_VARIANT_SCHEMA = { type: "object", properties: { size: { type: "string" }, width: { type: "number" }, height: { type: "number" }, format: { type: "string" }, bytes: { type: "number" }, state: { type: "string", enum: ["pending", "done", "error", "failed"] }, path: { type: "string" } }, required: ["size", "width", "height", "format", "bytes", "state", "path"] };
33
+ export default defineModule({
34
+ name: "images/default",
35
+ provides: [],
36
+ requires: [BLOBSTORE, PERSISTENCE],
37
+ configSchema,
38
+ async setup(config, deps) {
39
+ return createImages(config, { blobs: deps.get(BLOBSTORE), db: deps.get(PERSISTENCE), logger: deps.logger });
40
+ },
41
+ steps: (images) => ({
42
+ register: async (ctx) => {
43
+ const sizes = await images.register(ctx.payload.sizes);
44
+ if (isErr(sizes))
45
+ return ctx.fail(sizes.error);
46
+ return ok({ ...ctx, result: sizes.value });
47
+ },
48
+ listSizes: async (ctx) => ok({ ...ctx, result: await images.sizes() }),
49
+ generate: async (ctx) => {
50
+ const idsPayload = ctx.payload.ids;
51
+ if (Array.isArray(idsPayload)) {
52
+ const items = [];
53
+ for (const id of idsPayload.filter((value) => typeof value === "string")) {
54
+ const known = await images.exists(id);
55
+ if (isErr(known))
56
+ return ctx.fail(known.error);
57
+ if (!known.value)
58
+ continue;
59
+ const variants = await images.generate(id);
60
+ if (isErr(variants))
61
+ return ctx.fail(variants.error);
62
+ items.push({ id, variants: variants.value });
63
+ }
64
+ return ok({ ...ctx, result: { items } });
65
+ }
66
+ const id = mediaId(ctx);
67
+ if (!id)
68
+ return ctx.fail("VALIDATION", "images: missing media id");
69
+ const known = await images.exists(id);
70
+ if (isErr(known))
71
+ return ctx.fail(known.error);
72
+ if (!known.value)
73
+ return ctx.fail("NOT_FOUND", `images: media/${id} not found`);
74
+ const variants = await images.generate(id);
75
+ if (isErr(variants))
76
+ return ctx.fail(variants.error);
77
+ return ok({ ...ctx, result: { id, variants: variants.value } });
78
+ },
79
+ sync: async (ctx) => {
80
+ const job = await images.sync();
81
+ if (isErr(job))
82
+ return ctx.fail(job.error);
83
+ return ok({ ...ctx, result: job.value });
84
+ },
85
+ resume: async (ctx) => {
86
+ const job = await images.resume();
87
+ if (isErr(job))
88
+ return ctx.fail(job.error);
89
+ return ok({ ...ctx, result: job.value });
90
+ },
91
+ prune: async (ctx) => {
92
+ const names = ctx.payload.sizes;
93
+ if (!Array.isArray(names) || names.some((name) => typeof name !== "string"))
94
+ return ctx.fail("VALIDATION", "images: missing sizes");
95
+ const pruned = await images.prune(names);
96
+ if (isErr(pruned))
97
+ return ctx.fail(pruned.error);
98
+ return ok({ ...ctx, result: pruned.value });
99
+ },
100
+ readStatus: async (ctx) => {
101
+ const status = await images.status();
102
+ if (isErr(status))
103
+ return ctx.fail(status.error);
104
+ return ok({ ...ctx, result: status.value });
105
+ },
106
+ remove: async (ctx) => {
107
+ if (!ctx.params.id)
108
+ return ctx.fail("VALIDATION", "images: missing id");
109
+ const removed = await images.remove(ctx.params.id);
110
+ if (isErr(removed))
111
+ return ctx.fail(removed.error);
112
+ return ok(ctx);
113
+ },
114
+ removeMany: async (ctx) => {
115
+ const ids = ctx.result?.ids;
116
+ if (!Array.isArray(ids))
117
+ throw new Error("images: removeMany: no ids in result");
118
+ for (const id of ids) {
119
+ const removed = await images.remove(id);
120
+ if (isErr(removed))
121
+ return ctx.fail(removed.error);
122
+ }
123
+ return ok(ctx);
124
+ },
125
+ attach: async (ctx) => {
126
+ const result = ctx.result;
127
+ if (typeof result?.id === "string") {
128
+ const byMedia = await images.variantsOf([result.id]);
129
+ if (isErr(byMedia))
130
+ return ctx.fail(byMedia.error);
131
+ const variants = byMedia.value.get(result.id) ?? [];
132
+ return ok({ ...ctx, result: { ...result, variants: variants.map((v) => attachedVariant(images, v)) } });
133
+ }
134
+ if (Array.isArray(result?.items)) {
135
+ const items = result.items;
136
+ const ids = items.map((item) => item.id).filter((id) => typeof id === "string");
137
+ const byMedia = await images.variantsOf(ids);
138
+ if (isErr(byMedia))
139
+ return ctx.fail(byMedia.error);
140
+ const withVariants = items.map((item) => {
141
+ const variants = typeof item.id === "string" ? (byMedia.value.get(item.id) ?? []) : [];
142
+ return { ...item, variants: variants.map((v) => attachedVariant(images, v)) };
143
+ });
144
+ return ok({ ...ctx, result: { ...result, items: withVariants } });
145
+ }
146
+ throw new Error("images: attach: no media item(s) in result");
147
+ },
148
+ serve: async (ctx) => {
149
+ const id = ctx.params.id;
150
+ const file = ctx.params.file;
151
+ if (!id || !file)
152
+ return ctx.fail("VALIDATION", "images: missing id or file");
153
+ const found = await images.read(id, file);
154
+ if (isErr(found))
155
+ return ctx.fail(found.error);
156
+ if (found.value === null) {
157
+ const failed = await images.failure(id, file);
158
+ if (isErr(failed))
159
+ return ctx.fail(failed.error);
160
+ if (failed.value)
161
+ return ctx.fail("NOT_FOUND", `images: variant ${failed.value.size} for media/${id} failed after ${failed.value.attempts} attempts: ${failed.value.error ?? "unknown error"}`);
162
+ return ctx.fail("NOT_FOUND", `images: media/${id}/variants/${file} not found`);
163
+ }
164
+ const result = binaryResult(found.value.data, found.value.contentType, file);
165
+ if (found.value.fallback)
166
+ result.headers = { "x-kestrel-variant": found.value.variant };
167
+ return ok({ ...ctx, result });
168
+ },
169
+ export: stepFactory((dir) => async (ctx) => {
170
+ const variants = await images.exportTo(dir);
171
+ if (isErr(variants))
172
+ return ctx.fail(variants.error);
173
+ // the step runs after media.export in the same pipeline; replacing ctx.result would drop that
174
+ // step's counts, so the variant counts are nested under their own key instead.
175
+ const previous = typeof ctx.result === "object" && ctx.result !== null && !Array.isArray(ctx.result) ? ctx.result : {};
176
+ return ok({ ...ctx, result: { ...previous, variants: variants.value } });
177
+ }),
178
+ }),
179
+ teardown: (images) => images.close(),
180
+ describe: () => ({
181
+ register: { summary: "Replace the sizes registered by this instance – code-declared sizes are held in memory, never stored", reads: [], writes: ["result"], input: { type: "object", properties: { sizes: { type: "array", items: SIZE_INPUT_SCHEMA } }, required: ["sizes"] }, output: { type: "array", items: SIZE_SCHEMA }, errors: { 400: "empty list or invalid size definition", 409: "a size collides with a config size" } },
182
+ listSizes: { summary: "Effective sizes (defaults/config merged with the sizes registered in this process)", reads: [], writes: ["result"], output: { type: "array", items: SIZE_SCHEMA } },
183
+ generate: { summary: "Generate variants for one media item (id from params.id or payload.id), or for each id in payload.ids (unknown ids are skipped)", reads: [], writes: ["result"], output: { oneOf: [{ type: "object", properties: { id: { type: "string" }, variants: { type: "array", items: VARIANT_SCHEMA } }, required: ["id", "variants"] }, { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { id: { type: "string" }, variants: { type: "array", items: VARIANT_SCHEMA } }, required: ["id", "variants"] } } }, required: ["items"] }] }, errors: { 400: "missing media id", 404: "media item not found" } },
184
+ sync: { summary: "Start a sync job, or resume a paused/error/stale one", reads: [], writes: ["result"], output: JOB_SCHEMA, errors: { 409: "a fresh job is already running" } },
185
+ resume: { summary: "Resume a paused/error/stale job, or no-op (for cron)", reads: [], writes: ["result"], output: { oneOf: [JOB_SCHEMA, { type: "null" }] } },
186
+ prune: { summary: "Delete the variants of sizes that are no longer declared", reads: [], writes: ["result"], input: { type: "object", properties: { sizes: { type: "array", items: { type: "string" } } }, required: ["sizes"] }, output: { type: "object", properties: { sizes: { type: "number" }, variants: { type: "number" } }, required: ["sizes", "variants"] }, errors: { 400: "missing sizes, or a name is still declared or has no variants" } },
187
+ readStatus: {
188
+ summary: "Sizes with usage/variant counts, current job, and sizes whose variants are left over from a size the code no longer declares",
189
+ reads: [],
190
+ writes: ["result"],
191
+ output: {
192
+ type: "object",
193
+ properties: {
194
+ sizes: { type: "array", items: { type: "object", properties: { ...SIZE_SCHEMA.properties, used: { type: "boolean" }, variants: { type: "object", properties: { done: { type: "number" }, pending: { type: "number" }, error: { type: "number" }, failed: { type: "number" } }, required: ["done", "pending", "error", "failed"] } }, required: [...SIZE_SCHEMA.required, "used", "variants"] } },
195
+ job: { oneOf: [JOB_SCHEMA, { type: "null" }] },
196
+ orphaned: { type: "object", properties: { sizes: { type: "array", items: { type: "string" } }, variants: { type: "number" } }, required: ["sizes", "variants"] },
197
+ registrySeen: { type: "boolean" },
198
+ },
199
+ required: ["sizes", "job", "orphaned", "registrySeen"],
200
+ },
201
+ },
202
+ remove: { summary: "Delete one media item's variants (blob + rows)", reads: ["params.id"], writes: [], errors: { 400: "missing id" } },
203
+ removeMany: { summary: "Delete variants for every id in result.ids (from media.folderItems)", reads: ["result.ids"], writes: [] },
204
+ attach: { summary: "Add variants[] to a media item or list (result.id or result.items)", reads: ["result"], writes: ["result.variants"], extendsItems: { type: "object", properties: { variants: { type: "array", items: ATTACHED_VARIANT_SCHEMA } }, required: ["variants"] } },
205
+ serve: { summary: "Binary variant, or the original with x-kestrel-variant: pending while it isn't ready yet", reads: ["params.id", "params.file"], writes: ["result"], binary: true, errors: { 400: "missing id or file", 404: "media item or size not found, or the variant gave up after maxAttempts" } },
206
+ export: (dir) => ({ summary: `Copy every done variant to ${dir}/<folder>/<filename>.<size>.<ext>`, reads: [], writes: ["result.variants"], extendsOutput: { type: "object", properties: { variants: { type: "object", properties: { written: { type: "number" }, skipped: { type: "number" } }, required: ["written", "skipped"] } }, required: ["variants"] } }),
207
+ }),
208
+ });
@@ -0,0 +1,49 @@
1
+ import { type KestrelError } from "@michaelthielemann/kestrel/errors";
2
+ import { type Result } from "@michaelthielemann/kestrel/result";
3
+ import { z } from "zod";
4
+ export type SizeSource = "default" | "config" | "registered";
5
+ export interface SizeRow extends Size {
6
+ source: SizeSource;
7
+ updatedAt: number;
8
+ }
9
+ export declare const DEFAULT_SIZES: Size[];
10
+ export declare const sizeSchema: z.ZodEffects<z.ZodObject<{
11
+ name: z.ZodString;
12
+ width: z.ZodNumber;
13
+ height: z.ZodOptional<z.ZodNumber>;
14
+ fit: z.ZodDefault<z.ZodEnum<["inside", "cover"]>>;
15
+ format: z.ZodDefault<z.ZodEnum<["webp", "original"]>>;
16
+ quality: z.ZodDefault<z.ZodNumber>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ name: string;
19
+ width: number;
20
+ fit: "inside" | "cover";
21
+ format: "webp" | "original";
22
+ quality: number;
23
+ height?: number | undefined;
24
+ }, {
25
+ name: string;
26
+ width: number;
27
+ height?: number | undefined;
28
+ fit?: "inside" | "cover" | undefined;
29
+ format?: "webp" | "original" | undefined;
30
+ quality?: number | undefined;
31
+ }>, {
32
+ name: string;
33
+ width: number;
34
+ fit: "inside" | "cover";
35
+ format: "webp" | "original";
36
+ quality: number;
37
+ height?: number | undefined;
38
+ }, {
39
+ name: string;
40
+ width: number;
41
+ height?: number | undefined;
42
+ fit?: "inside" | "cover" | undefined;
43
+ format?: "webp" | "original" | undefined;
44
+ quality?: number | undefined;
45
+ }>;
46
+ export type Size = z.output<typeof sizeSchema>;
47
+ export declare function spec(size: Size): string;
48
+ export declare function extOf(size: Size, originalFormat: string): string;
49
+ export declare function mergeSizes(base: Size[], baseSource: "default" | "config", registered: Size[]): Result<SizeRow[], KestrelError<"CONFLICT">>;
package/dist/sizes.js ADDED
@@ -0,0 +1,47 @@
1
+ import { failure } from "@michaelthielemann/kestrel/errors";
2
+ import { err, ok } from "@michaelthielemann/kestrel/result";
3
+ import { z } from "zod";
4
+ export const DEFAULT_SIZES = [
5
+ { name: "thumb", width: 320, fit: "inside", format: "webp", quality: 82 },
6
+ { name: "small", width: 640, fit: "inside", format: "webp", quality: 82 },
7
+ { name: "medium", width: 1024, fit: "inside", format: "webp", quality: 82 },
8
+ { name: "large", width: 1600, fit: "inside", format: "webp", quality: 82 },
9
+ { name: "xl", width: 2400, fit: "inside", format: "webp", quality: 82 },
10
+ ];
11
+ export const sizeSchema = z
12
+ .object({
13
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
14
+ width: z.number().int().min(16).max(8192),
15
+ height: z.number().int().min(16).max(8192).optional(),
16
+ fit: z.enum(["inside", "cover"]).default("inside"),
17
+ format: z.enum(["webp", "original"]).default("webp"),
18
+ quality: z.number().int().min(1).max(100).default(82),
19
+ })
20
+ .superRefine((size, ctx) => {
21
+ if (size.fit === "cover" && size.height === undefined) {
22
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "height is required for fit \"cover\"", path: ["height"] });
23
+ }
24
+ });
25
+ export function spec(size) {
26
+ return `${size.width}x${size.height ?? ""}:${size.fit}:${size.format}:${size.quality}`;
27
+ }
28
+ const EXT_ALIASES = { jpeg: "jpg" };
29
+ export function extOf(size, originalFormat) {
30
+ if (size.format === "webp")
31
+ return "webp";
32
+ return EXT_ALIASES[originalFormat] ?? originalFormat;
33
+ }
34
+ export function mergeSizes(base, baseSource, registered) {
35
+ const now = Date.now();
36
+ const rows = new Map();
37
+ for (const size of base)
38
+ rows.set(size.name, { ...size, source: baseSource, updatedAt: now });
39
+ for (const size of registered) {
40
+ const existing = rows.get(size.name);
41
+ if (baseSource === "config" && existing?.source === "config" && spec(existing) !== spec(size)) {
42
+ return err(failure("CONFLICT", `images: size "${size.name}" is defined in config`));
43
+ }
44
+ rows.set(size.name, { ...size, source: "registered", updatedAt: now });
45
+ }
46
+ return ok([...rows.values()]);
47
+ }