@spinekit/media 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,628 @@
1
+ import { FOLDER_CONTENT_TYPE_MAP, IMAGE_SETTINGS, SIZE_VARIANTS } from "./config/media.defaults.mjs";
2
+ import { defineResource, mergeResourceConfig } from "@classytic/arc";
3
+ import { createEngineSlot } from "@spinekit/kit/engine-slot";
4
+ import { resolveSpineTenant } from "@spinekit/kit/tenant";
5
+ import { scopeFirstCtx } from "@classytic/arc/scope";
6
+ import { QueryParser } from "@classytic/mongokit";
7
+ import { createMongooseAdapter } from "@classytic/mongokit/adapter";
8
+ import { allowPublic, requireOrgRole } from "@classytic/arc/permissions";
9
+ import { defineEngineModule } from "@spinekit/kit/engine-module";
10
+ import { MEDIA_TRANSFORM_POLICY_VERSION } from "@classytic/media-transform/policy";
11
+ import { confirmUploadSchema, startWriteSchema } from "@classytic/media-kit/schemas";
12
+ import { createAssetTransform } from "@classytic/media-kit/transforms";
13
+ //#region src/config/transform-policy.ts
14
+ /** Longest edge a size variant occupies, from its width/height box. */
15
+ function edgeOf(variant) {
16
+ return Math.max(variant.width ?? 0, variant.height ?? 0);
17
+ }
18
+ /**
19
+ * Build the policy document served to clients.
20
+ *
21
+ * Ordering matters and is deliberate: untouched folders are matched FIRST, so a
22
+ * deployment can never accidentally shadow them with a broader imagery rule.
23
+ */
24
+ function buildTransformPolicy(input = {}) {
25
+ const derivatives = SIZE_VARIANTS.map((v) => ({
26
+ name: v.name,
27
+ maxEdge: edgeOf(v),
28
+ quality: v.quality !== void 0 ? v.quality / 100 : void 0
29
+ })).filter((d) => d.maxEdge > 0);
30
+ const rules = [];
31
+ if (input.untouchedFolders?.length) rules.push({
32
+ id: "originals-untouched",
33
+ when: { folder: [...input.untouchedFolders] },
34
+ use: false
35
+ });
36
+ const avatarFolders = FOLDER_CONTENT_TYPE_MAP.avatar;
37
+ if (avatarFolders.length > 0) rules.push({
38
+ id: "avatars",
39
+ when: { folder: [...avatarFolders] },
40
+ use: { preset: "avatar" }
41
+ });
42
+ return {
43
+ version: MEDIA_TRANSFORM_POLICY_VERSION,
44
+ rules,
45
+ /**
46
+ * Ordinary imagery — product, category, banner, brand and anything a
47
+ * deployment adds later. `ecom` keeps zoomable detail; the derivatives are
48
+ * the server's own declared variants, now produced on the device that
49
+ * already holds the decoded bitmap.
50
+ *
51
+ * A fallback is REQUIRED by the contract, so "no rule matched" can never
52
+ * mean "client decides".
53
+ */
54
+ fallback: {
55
+ preset: "ecom",
56
+ maxEdge: input.maxEdge ?? IMAGE_SETTINGS.defaultMaxWidth,
57
+ derivatives
58
+ }
59
+ };
60
+ }
61
+ //#endregion
62
+ //#region src/cleanup.ts
63
+ /** Count candidates through the repository so tenant scoping still applies. */
64
+ async function countCandidates(repo, filters, options) {
65
+ const found = await repo.getAll({ filters }, {
66
+ lean: true,
67
+ ...options
68
+ });
69
+ return Array.isArray(found) ? found.length : 0;
70
+ }
71
+ /**
72
+ * The three media purge steps, in the order a full cleanup should run them:
73
+ * stale uploads first (cheapest, unblocks nothing), then expired assets, then
74
+ * the soft-delete backlog.
75
+ */
76
+ function mediaCleanupSteps(engine, options = {}) {
77
+ const repo = engine.repositories.media;
78
+ const ctx = options.ctx;
79
+ const tenantOpts = () => repo._tenantOpts?.(ctx) ?? {};
80
+ return [
81
+ {
82
+ id: "media.stale-pending",
83
+ resource: "abandoned media uploads",
84
+ destructive: true,
85
+ async estimate(stepCtx) {
86
+ const cutoff = options.stalePendingOlderThan ?? stepCtx.now;
87
+ const estimated = await countCandidates(repo, {
88
+ status: { $in: ["pending", "deleting"] },
89
+ createdAt: { $lt: cutoff }
90
+ }, tenantOpts());
91
+ return {
92
+ resource: this.resource,
93
+ estimated,
94
+ retained: "completed uploads untouched",
95
+ warnings: ["Removes the storage object as well as the row — irreversible.", "Not chunked: the kernel loads all candidates before deleting, so a cancel lands after this step, not mid-sweep."]
96
+ };
97
+ },
98
+ async execute(stepCtx) {
99
+ await stepCtx.throwIfCancelled?.();
100
+ try {
101
+ const processed = await repo.purgeStalePending(options.stalePendingOlderThan, ctx);
102
+ await stepCtx.onProgress?.({
103
+ resource: this.resource,
104
+ processed
105
+ });
106
+ return {
107
+ resource: this.resource,
108
+ processed,
109
+ ok: true
110
+ };
111
+ } catch (error) {
112
+ return {
113
+ resource: this.resource,
114
+ processed: 0,
115
+ ok: false,
116
+ error: error instanceof Error ? error.message : String(error)
117
+ };
118
+ }
119
+ },
120
+ async verify(stepCtx) {
121
+ const cutoff = options.stalePendingOlderThan ?? stepCtx.now;
122
+ const remaining = await countCandidates(repo, {
123
+ status: { $in: ["pending", "deleting"] },
124
+ createdAt: { $lt: cutoff }
125
+ }, tenantOpts());
126
+ return [{
127
+ name: "media.stale-pending.drained",
128
+ ok: remaining === 0,
129
+ detail: remaining === 0 ? "no stale uploads remain" : `${remaining} still present`
130
+ }];
131
+ }
132
+ },
133
+ {
134
+ id: "media.expired",
135
+ resource: "expired media assets",
136
+ destructive: true,
137
+ async estimate(stepCtx) {
138
+ const cutoff = options.expiredBefore ?? stepCtx.now;
139
+ const estimated = await countCandidates(repo, { expiresAt: {
140
+ $ne: null,
141
+ $lte: cutoff
142
+ } }, tenantOpts());
143
+ return {
144
+ resource: this.resource,
145
+ estimated,
146
+ retained: "assets without an expiry, and those not yet expired",
147
+ warnings: ["Removes the storage object as well as the row — irreversible."]
148
+ };
149
+ },
150
+ async execute(stepCtx) {
151
+ await stepCtx.throwIfCancelled?.();
152
+ try {
153
+ const result = await repo.purgeExpired(options.expiredBefore, ctx);
154
+ const processed = result.success.length;
155
+ await stepCtx.onProgress?.({
156
+ resource: this.resource,
157
+ processed
158
+ });
159
+ return {
160
+ resource: this.resource,
161
+ processed,
162
+ ok: result.failed.length === 0,
163
+ ...result.failed.length > 0 ? { error: `${result.failed.length} asset(s) failed to purge` } : {}
164
+ };
165
+ } catch (error) {
166
+ return {
167
+ resource: this.resource,
168
+ processed: 0,
169
+ ok: false,
170
+ error: error instanceof Error ? error.message : String(error)
171
+ };
172
+ }
173
+ },
174
+ async verify(stepCtx) {
175
+ const cutoff = options.expiredBefore ?? stepCtx.now;
176
+ const remaining = await countCandidates(repo, { expiresAt: {
177
+ $ne: null,
178
+ $lte: cutoff
179
+ } }, tenantOpts());
180
+ return [{
181
+ name: "media.expired.drained",
182
+ ok: remaining === 0,
183
+ detail: remaining === 0 ? "no expired assets remain" : `${remaining} still present`
184
+ }];
185
+ }
186
+ },
187
+ {
188
+ id: "media.soft-deleted",
189
+ resource: "soft-deleted media",
190
+ destructive: true,
191
+ async estimate(_stepCtx) {
192
+ const estimated = options.deletedOlderThan ? await countCandidates(repo, { deletedAt: {
193
+ $ne: null,
194
+ $lt: options.deletedOlderThan
195
+ } }, {
196
+ includeDeleted: true,
197
+ ...tenantOpts()
198
+ }) : await countCandidates(repo, { deletedAt: { $ne: null } }, {
199
+ includeDeleted: true,
200
+ ...tenantOpts()
201
+ });
202
+ return {
203
+ resource: this.resource,
204
+ estimated,
205
+ retained: "live media untouched",
206
+ warnings: [
207
+ "Removes the storage object as well as the row — irreversible.",
208
+ ...options.deletedOlderThan ? [] : ["No explicit cutoff: the kernel default (softDelete.ttlDays, 30d) applies, so this estimate counts the FULL soft-deleted set and may overstate what is purged. Pass `deletedOlderThan` for an exact preview."],
209
+ "Not chunked: the kernel loads all candidates before deleting."
210
+ ]
211
+ };
212
+ },
213
+ async execute(stepCtx) {
214
+ await stepCtx.throwIfCancelled?.();
215
+ try {
216
+ const processed = await repo.purgeDeleted(options.deletedOlderThan, ctx);
217
+ await stepCtx.onProgress?.({
218
+ resource: this.resource,
219
+ processed
220
+ });
221
+ return {
222
+ resource: this.resource,
223
+ processed,
224
+ ok: true
225
+ };
226
+ } catch (error) {
227
+ return {
228
+ resource: this.resource,
229
+ processed: 0,
230
+ ok: false,
231
+ error: error instanceof Error ? error.message : String(error)
232
+ };
233
+ }
234
+ },
235
+ async verify(_stepCtx) {
236
+ if (!options.deletedOlderThan) return [{
237
+ name: "media.soft-deleted.swept",
238
+ ok: true,
239
+ detail: `${await countCandidates(repo, { deletedAt: { $ne: null } }, {
240
+ includeDeleted: true,
241
+ ...tenantOpts()
242
+ })} soft-deleted row(s) remain inside the kernel retention window`
243
+ }];
244
+ const remaining = await countCandidates(repo, { deletedAt: {
245
+ $ne: null,
246
+ $lt: options.deletedOlderThan
247
+ } }, {
248
+ includeDeleted: true,
249
+ ...tenantOpts()
250
+ });
251
+ return [{
252
+ name: "media.soft-deleted.drained",
253
+ ok: remaining === 0,
254
+ detail: remaining === 0 ? "no purgeable soft-deleted media remain" : `${remaining} still present`
255
+ }];
256
+ }
257
+ }
258
+ ];
259
+ }
260
+ //#endregion
261
+ //#region src/index.ts
262
+ function createMediaRolePermissions(input) {
263
+ if (input.view.length === 0 || input.upload.length === 0 || input.manage.length === 0) throw new Error("createMediaRolePermissions: role lists must not be empty");
264
+ return {
265
+ view: requireOrgRole(...input.view),
266
+ upload: requireOrgRole(...input.upload),
267
+ manage: requireOrgRole(...input.manage)
268
+ };
269
+ }
270
+ function createMediaEngineSlot() {
271
+ return createEngineSlot("media");
272
+ }
273
+ /** Actor context from the arc request — the actor is ALWAYS the authenticated user. */
274
+ function ctxOf(req) {
275
+ const { actorId: userId, organizationId } = scopeFirstCtx(req, { orgHeader: false });
276
+ return {
277
+ ...userId !== void 0 ? { userId } : {},
278
+ ...organizationId !== void 0 ? { organizationId } : {}
279
+ };
280
+ }
281
+ /**
282
+ * Parse a command body against a STRICT schema, or 400 with the reason.
283
+ *
284
+ * Replaces per-route `typeof` checks followed by a cast. Those validated the
285
+ * two or three fields someone remembered and passed everything else through
286
+ * untouched — so an invalid `hashStrategy`, a negative dimension, a malformed
287
+ * hash or an unknown key reached kernel code, and Zod's default STRIP would
288
+ * have quietly dropped the unknown one rather than refusing it. For an upload
289
+ * command, dropping a key does not fail; it executes a different instruction
290
+ * than the caller wrote.
291
+ */
292
+ function parseBody(reply, schema, body) {
293
+ const result = schema.safeParse(body ?? {});
294
+ if (!result.success) {
295
+ badRequest(reply, (result.error?.issues ?? []).slice(0, 4).map((i) => `${i.path.join(".") || "(body)"}: ${i.message}`).join("; ") || "invalid request body");
296
+ return;
297
+ }
298
+ return result.data;
299
+ }
300
+ function badRequest(reply, message) {
301
+ return reply.code(400).send({
302
+ code: "media.invalid_input",
303
+ message,
304
+ status: 400
305
+ });
306
+ }
307
+ const DEFAULT_MULTIPART_THRESHOLD = 104857600;
308
+ function createMediaResource(deps) {
309
+ const { permissions } = deps;
310
+ const media = deps.engine.repositories.media;
311
+ const threshold = deps.multipartThresholdBytes ?? DEFAULT_MULTIPART_THRESHOLD;
312
+ const transformPolicy = deps.transformPolicy ?? buildTransformPolicy();
313
+ const assetTransform = createAssetTransform({ media: deps.engine });
314
+ const serveAsset = async (req, reply) => {
315
+ const params = req.params;
316
+ const query = Object.fromEntries(Object.entries(req.query).filter((entry) => typeof entry[1] === "string"));
317
+ const result = await assetTransform.handle({
318
+ fileId: params.id,
319
+ ...params.variant ? { variant: params.variant } : {},
320
+ params: req.query,
321
+ query,
322
+ ...typeof req.headers.accept === "string" ? { accept: req.headers.accept } : {},
323
+ ...typeof req.headers.range === "string" ? { range: req.headers.range } : {},
324
+ principal: req.user ?? req.scope
325
+ });
326
+ reply.code(result.status).headers(result.headers);
327
+ return reply.send(result.stream);
328
+ };
329
+ return defineResource(mergeResourceConfig({
330
+ name: "media",
331
+ displayName: "Media",
332
+ tag: "Media",
333
+ prefix: deps.prefix ?? "/media",
334
+ audit: true,
335
+ tenantField: resolveSpineTenant(deps.engine.config.tenant).resourceTenantField,
336
+ adapter: createMongooseAdapter({
337
+ model: deps.engine.models.Media,
338
+ repository: media
339
+ }),
340
+ queryParser: new QueryParser({
341
+ maxLimit: 100,
342
+ searchMode: "regex",
343
+ searchFields: [
344
+ "filename",
345
+ "title",
346
+ "tags"
347
+ ],
348
+ allowedFilterFields: [
349
+ "status",
350
+ "mimeType",
351
+ "folder",
352
+ "visibility",
353
+ "hash",
354
+ "filename",
355
+ "tags"
356
+ ],
357
+ allowedSortFields: [
358
+ "createdAt",
359
+ "size",
360
+ "filename"
361
+ ]
362
+ }),
363
+ disabledRoutes: ["create", "update"],
364
+ permissions: {
365
+ list: permissions.view,
366
+ get: permissions.view,
367
+ delete: permissions.manage
368
+ },
369
+ actions: { "signed-url": {
370
+ handler: async (id, data, req) => {
371
+ const opts = data ?? {};
372
+ return { url: await media.getSignedAssetUrl(id, {
373
+ ...opts.variant !== void 0 ? { variant: opts.variant } : {},
374
+ ...opts.expiresIn !== void 0 ? { expiresIn: opts.expiresIn } : {}
375
+ }, ctxOf(req)) };
376
+ },
377
+ permissions: permissions.view
378
+ } },
379
+ routes: [
380
+ {
381
+ method: "GET",
382
+ path: "/content/:id",
383
+ summary: "Serve public media or private media bearing a valid signed URL",
384
+ permissions: allowPublic(),
385
+ rawHandler: serveAsset
386
+ },
387
+ {
388
+ method: "GET",
389
+ path: "/content/:id/:variant",
390
+ summary: "Serve a named media variant bearing a valid signed URL",
391
+ permissions: allowPublic(),
392
+ rawHandler: serveAsset
393
+ },
394
+ {
395
+ method: "POST",
396
+ path: "/start-write",
397
+ summary: "Begin a direct-to-storage upload: dedup short-circuit, presigned PUT, or multipart/resumable session",
398
+ permissions: permissions.upload,
399
+ rawHandler: async (req, reply) => {
400
+ const body = parseBody(reply, startWriteSchema, req.body);
401
+ if (!body) return;
402
+ const ctx = ctxOf(req);
403
+ if (typeof body.sha256 === "string" && body.sha256.length > 0) {
404
+ const hit = await media.existsByHash(body.sha256, ctx, { ...typeof body.size === "number" ? { expectedSize: body.size } : {} });
405
+ if (hit.exists && hit.media) {
406
+ const result = {
407
+ kind: "dedup",
408
+ media: hit.media
409
+ };
410
+ return reply.code(200).send(result);
411
+ }
412
+ }
413
+ if (body.multipart === true || body.partCount !== void 0 || typeof body.size === "number" && body.size >= threshold) {
414
+ const input = {
415
+ filename: body.filename,
416
+ contentType: body.contentType,
417
+ ...body.folder !== void 0 ? { folder: body.folder } : {},
418
+ ...body.partCount !== void 0 ? { partCount: body.partCount } : {},
419
+ ...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
420
+ };
421
+ const session = await media.initiateMultipartUpload(input, ctx);
422
+ const result = {
423
+ kind: session.type,
424
+ session
425
+ };
426
+ return reply.code(201).send(result);
427
+ }
428
+ const upload = await media.getSignedUploadUrl(body.filename, body.contentType, {
429
+ ...body.folder !== void 0 ? { folder: body.folder } : {},
430
+ ...body.size !== void 0 ? { size: body.size } : {},
431
+ ...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
432
+ }, ctx);
433
+ /**
434
+ * Presign the sizes the client made from the SAME decode.
435
+ *
436
+ * Requested in this call rather than a second round trip: the
437
+ * transform policy already told the client which sizes to make,
438
+ * and it made them before it had anywhere to put them.
439
+ *
440
+ * A failure here FAILS the whole start — not a partial success.
441
+ * Handing back a primary URL with no derivative URLs would look
442
+ * identical to "this policy asks for none", and the client would
443
+ * upload one file believing it had done the job.
444
+ */
445
+ const derivativeUploads = body.derivatives && body.derivatives.length > 0 ? await media.signDerivativeUploads({
446
+ primaryKey: upload.key,
447
+ derivatives: body.derivatives,
448
+ ...body.expiresIn !== void 0 ? { expiresIn: body.expiresIn } : {}
449
+ }, ctx) : void 0;
450
+ const result = {
451
+ kind: "presigned",
452
+ upload,
453
+ ...derivativeUploads?.length ? { derivativeUploads } : {}
454
+ };
455
+ return reply.code(201).send(result);
456
+ }
457
+ },
458
+ {
459
+ method: "GET",
460
+ path: "/transform-policy",
461
+ summary: "What a client should do to an upload before sending it (host-owned)",
462
+ /**
463
+ * Readable by anyone who can VIEW media, not just uploaders: the
464
+ * policy is a description of the deployment's own rules, and a
465
+ * client resolves it before it knows whether it will upload.
466
+ */
467
+ permissions: permissions.view,
468
+ rawHandler: async (_req, reply) => {
469
+ /**
470
+ * Cacheable, and deliberately so — this document changes on
471
+ * deploy, not per request, and every upload consults it. It is
472
+ * versioned, so a client that does not understand the version
473
+ * declines to transform rather than guessing.
474
+ */
475
+ reply.header("cache-control", "public, max-age=300");
476
+ return reply.code(200).send(transformPolicy);
477
+ }
478
+ },
479
+ {
480
+ method: "POST",
481
+ path: "/complete-write",
482
+ summary: "Confirm a presigned upload — storage-verified (exists/stat/MIME), stores client display hints",
483
+ permissions: permissions.upload,
484
+ rawHandler: async (req, reply) => {
485
+ const body = parseBody(reply, confirmUploadSchema, req.body);
486
+ if (!body) return;
487
+ const doc = await media.confirmUpload(body, ctxOf(req));
488
+ return reply.code(201).send(doc);
489
+ }
490
+ },
491
+ {
492
+ method: "POST",
493
+ path: "/sign-parts",
494
+ summary: "Sign multipart upload part URLs on demand",
495
+ permissions: permissions.upload,
496
+ rawHandler: async (req, reply) => {
497
+ const body = req.body ?? {};
498
+ if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
499
+ if (!Array.isArray(body.partNumbers) || body.partNumbers.length === 0) return badRequest(reply, "`partNumbers` must be a non-empty array");
500
+ const parts = await media.signUploadParts(body.key, body.uploadId, body.partNumbers, body.expiresIn);
501
+ return reply.code(200).send({ parts });
502
+ }
503
+ },
504
+ {
505
+ method: "POST",
506
+ path: "/complete-multipart",
507
+ summary: "Assemble uploaded parts and register the asset (storage-verified)",
508
+ permissions: permissions.upload,
509
+ rawHandler: async (req, reply) => {
510
+ const body = req.body ?? {};
511
+ if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
512
+ if (!Array.isArray(body.parts) || body.parts.length === 0) return badRequest(reply, "`parts` must be a non-empty array");
513
+ if (typeof body.filename !== "string" || typeof body.mimeType !== "string") return badRequest(reply, "`filename` and `mimeType` are required");
514
+ const doc = await media.completeMultipartUpload(body, ctxOf(req));
515
+ return reply.code(201).send(doc);
516
+ }
517
+ },
518
+ {
519
+ method: "POST",
520
+ path: "/abort-multipart",
521
+ summary: "Abort an in-flight multipart upload (tenant-guarded)",
522
+ permissions: permissions.upload,
523
+ rawHandler: async (req, reply) => {
524
+ const body = req.body ?? {};
525
+ if (typeof body.key !== "string" || typeof body.uploadId !== "string") return badRequest(reply, "`key` and `uploadId` are required");
526
+ await media.abortMultipartUpload(body.key, body.uploadId, ctxOf(req));
527
+ return reply.code(204).send();
528
+ }
529
+ }
530
+ ]
531
+ }, deps.seams));
532
+ }
533
+ function createMediaModule(deps) {
534
+ const supplied = deps.source?.kind === "external" ? deps.source.engine : deps.engine;
535
+ const maintenance = deps.maintenance;
536
+ const { module } = defineEngineModule({
537
+ name: "media",
538
+ ...deps.dependsOn ? { dependsOn: [...deps.dependsOn] } : {},
539
+ ...supplied ? { supplied } : {},
540
+ ...deps.slot ? { slot: deps.slot } : {},
541
+ boot: async () => {
542
+ if (deps.source?.kind === "blueprint") {
543
+ const rt = typeof deps.source.runtime === "function" ? await deps.source.runtime() : deps.source.runtime;
544
+ return await deps.source.blueprint.bind(deps.source.connection, rt);
545
+ }
546
+ throw new Error("createMediaModule: supply a blueprint source or a live engine");
547
+ },
548
+ close: (engine) => engine.close(),
549
+ resources: (engine) => {
550
+ const { seams: seamsInput, source: _source, slot: _slot, ...resourceDeps } = deps;
551
+ const seams = typeof seamsInput === "function" ? seamsInput(engine) : seamsInput;
552
+ return [createMediaResource({
553
+ ...resourceDeps,
554
+ engine,
555
+ ...seams ? { seams } : {}
556
+ })];
557
+ },
558
+ ...deps.afterResources ? { afterResources: deps.afterResources } : {},
559
+ extend: (get) => maintenance === false ? {} : { scheduledJobs: () => mediaMaintenanceSchedules(get(), maintenance) }
560
+ });
561
+ return module;
562
+ }
563
+ /**
564
+ * Maintenance schedule definitions for arc's `schedulesPlugin` — drop into
565
+ * the host's schedule table (with a `lock` for multi-replica leader safety):
566
+ *
567
+ * ```ts
568
+ * await app.register(schedulesPlugin, {
569
+ * lock: createMongoLockAdapter({ connection }),
570
+ * schedules: [...mediaMaintenanceSchedules(engine), ...otherJobs],
571
+ * });
572
+ * ```
573
+ *
574
+ * ## What these sweeps do NOT cover — read before relying on them
575
+ *
576
+ * An abandoned TWO-PHASE upload leaves **no database row at all**. `start-write`
577
+ * only signs a URL; the record is created by `complete-write`. So a client that
578
+ * presigns and then never confirms leaves an object in the bucket that nothing
579
+ * here can see, let alone reclaim.
580
+ *
581
+ * This docblock previously said those uploads "leave `pending` docs that these
582
+ * sweeps reclaim", and treated bucket lifecycle as belt-and-braces. That was
583
+ * backwards, and it is the dangerous direction to be wrong in: a deployment
584
+ * reading it would skip the lifecycle rule and accumulate unreferenced objects
585
+ * it is still paying to store, with a maintenance job reporting success the
586
+ * whole time.
587
+ *
588
+ * **A bucket lifecycle expiry on incomplete/unreferenced keys is REQUIRED, not
589
+ * optional.** See media-kit's README ("Orphaned storage objects").
590
+ *
591
+ * What these sweeps genuinely reclaim: rows that DO exist and are stuck —
592
+ * `pending` records from the buffered upload path, soft-deleted docs past their
593
+ * retention window, and expired assets.
594
+ */
595
+ function mediaMaintenanceSchedules(engine, options = {}) {
596
+ const media = engine.repositories.media;
597
+ return [
598
+ {
599
+ name: "media.purge.stale-pending",
600
+ every: options.stalePendingEvery ?? 36e5,
601
+ leaseMs: Math.floor((options.stalePendingEvery ?? 36e5) * .9),
602
+ jitterMs: 6e4,
603
+ handler: async () => {
604
+ await media.purgeStalePending();
605
+ }
606
+ },
607
+ {
608
+ name: "media.purge.deleted",
609
+ every: options.purgeDeletedEvery ?? 864e5,
610
+ leaseMs: Math.floor((options.purgeDeletedEvery ?? 864e5) * .9),
611
+ jitterMs: 3e5,
612
+ handler: async () => {
613
+ await media.purgeDeleted();
614
+ }
615
+ },
616
+ {
617
+ name: "media.purge.expired",
618
+ every: options.purgeExpiredEvery ?? 36e5,
619
+ leaseMs: Math.floor((options.purgeExpiredEvery ?? 36e5) * .9),
620
+ jitterMs: 6e4,
621
+ handler: async () => {
622
+ await media.purgeExpired();
623
+ }
624
+ }
625
+ ];
626
+ }
627
+ //#endregion
628
+ export { createMediaEngineSlot, createMediaModule, createMediaResource, createMediaRolePermissions, mediaCleanupSteps, mediaMaintenanceSchedules };
@@ -0,0 +1,66 @@
1
+ import { StorageDriver } from "@classytic/media-kit";
2
+ //#region src/providers/media-provider.config.d.ts
3
+ type StorageDriverLike = import('@classytic/media-kit').StorageDriver;
4
+ interface S3ProviderConfig {
5
+ kind: 's3';
6
+ bucket: string;
7
+ region: string;
8
+ accessKeyId: string;
9
+ secretAccessKey: string;
10
+ publicUrl?: string;
11
+ acl?: string;
12
+ }
13
+ interface GcsProviderConfig {
14
+ kind: 'gcs';
15
+ bucket: string;
16
+ projectId?: string;
17
+ keyFilename?: string;
18
+ credentials?: Record<string, unknown>;
19
+ publicUrl?: string;
20
+ }
21
+ interface CloudflareImagesProviderConfig {
22
+ kind: 'cloudflare-images';
23
+ accountId: string;
24
+ apiToken: string;
25
+ deliveryUrl?: string;
26
+ }
27
+ interface CloudinaryProviderConfig {
28
+ kind: 'cloudinary';
29
+ cloudName: string;
30
+ apiKey: string;
31
+ apiSecret: string;
32
+ }
33
+ interface ImageKitProviderConfig {
34
+ kind: 'imagekit';
35
+ publicKey: string;
36
+ privateKey: string;
37
+ urlEndpoint: string;
38
+ }
39
+ interface ImgbbProviderConfig {
40
+ kind: 'imgbb';
41
+ apiKey: string;
42
+ }
43
+ interface LocalProviderConfig {
44
+ kind: 'local';
45
+ basePath: string;
46
+ publicUrl?: string;
47
+ }
48
+ /** No credentials, nothing persisted. Dev and tests only — never a production fallback. */
49
+ interface MemoryProviderConfig {
50
+ kind: 'memory';
51
+ }
52
+ type MediaProviderConfig = S3ProviderConfig | GcsProviderConfig | CloudflareImagesProviderConfig | CloudinaryProviderConfig | ImageKitProviderConfig | ImgbbProviderConfig | LocalProviderConfig | MemoryProviderConfig;
53
+ interface ResolveMediaProviderOptions {
54
+ /**
55
+ * Driver to use when `config` is absent or incomplete.
56
+ *
57
+ * EXPLICIT opt-in. Without it an incomplete config throws, because a silent fallback
58
+ * means uploads that appear to succeed and vanish.
59
+ */
60
+ fallback?: () => Promise<StorageDriverLike> | StorageDriverLike;
61
+ }
62
+ /** Which required fields are missing. Exported so a host can pre-flight its own config. */
63
+ declare function missingProviderFields(config: MediaProviderConfig): string[];
64
+ declare function resolveMediaProvider(config: MediaProviderConfig | undefined, options?: ResolveMediaProviderOptions): Promise<StorageDriverLike>;
65
+ //#endregion
66
+ export { CloudflareImagesProviderConfig, CloudinaryProviderConfig, GcsProviderConfig, ImageKitProviderConfig, ImgbbProviderConfig, LocalProviderConfig, MediaProviderConfig, MemoryProviderConfig, ResolveMediaProviderOptions, S3ProviderConfig, type StorageDriver, missingProviderFields, resolveMediaProvider };