@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/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 - 2026-08-21
4
+
5
+ ### Changed
6
+ - **License:** relicensed from MIT to the **Classytic Source-Available License**
7
+ (Community & Commercial). Evaluation and development use remain free; production
8
+ use now requires a commercial license from Classytic LLC. See `LICENSE`.
9
+
10
+ ### Notes
11
+ - First stable **1.0.0** release of the Classytic `arc` suite. Versions published
12
+ before 1.0.0 remain under their original MIT terms; the new license applies from
13
+ 1.0.0 onward.
14
+
15
+ ## 0.2.1 — 2026-07-22
16
+
17
+ - `ctxOf` now derives via arc 2.25's `scopeFirstCtx` (`orgHeader: false` keeps the scope-only org read; no fabricated actor — unauthenticated callers still yield no `userId`). Kills this package's copy of the per-package context-derivation clone (PACKAGE_RULES §4b.1). Peer floor: `@classytic/arc >=2.25.0`.
18
+
19
+ ## 0.2.0 — 2026-07-20
20
+
21
+ - **scheduledJobs arm**: createMediaModule now wires mediaMaintenanceSchedules into arc's scheduledJobs arm (opt out with maintenance: false). Peer floor raised @classytic/arc >=2.24.0.
22
+ - **BREAKING (names)**: schedule/lock names media-purge-* → canonical dotted media.purge.*.
23
+ - **owns colocation (PACKAGE_RULES §1.5)**: createMediaModule now declares `ArcModule.owns: ['media']`, mirroring its single resource. Hosts remove their hand-maintained supersession lists.
24
+ - Long-interval schedules set explicit leaseMs (~90% of every) — arc's default lease caps at 5min, which would let independently-phased replicas re-run hourly/daily sweeps.
25
+ - Actor/org context now reads arc's RequestScope (getUserId/getOrgId from @classytic/arc/scope) instead of hand-rolled req.user/header reads. For Better-Auth-org hosts this is behavior-preserving AND closes an unvalidated-header org-spoof hole; hosts on auth.type 'custom'/'authenticator' (arc leaves req.scope PUBLIC) must populate scope or the ctx degrades to anonymous — see arc resolveOrgFromHeader for the migration path.
26
+
27
+ ## 0.1.0 — 2026-07-18
28
+
29
+ Initial release.
30
+
31
+ - `createMediaModule` / `createMediaResource` — `@classytic/media-kit` (>=3.7)
32
+ as one Arc resource: adapter-backed reads, kernel-verb-only writes.
33
+ - Two-phase direct-to-storage flow: `POST /start-write` (presigned PUT or
34
+ multipart/resumable session by size/`partCount`), `POST /complete-write`
35
+ (storage-verified confirm + client display hints), `POST /sign-parts`,
36
+ `POST /complete-multipart`, `POST /abort-multipart` (ctx-guarded via
37
+ media-kit 3.7's tenant-aware abort — a leaked key+uploadId can't kill
38
+ another tenant's in-flight upload; pairs with react-media 0.4's
39
+ `createArcMediaProvider`).
40
+ - Content-hash dedup handshake: `start-write` with `sha256` short-circuits on
41
+ a tenant-scoped hit — pairs with `@classytic/media-transform`'s `sha256Hex`.
42
+ - `signed-url` action for time-boxed private reads.
43
+ - `mediaMaintenanceSchedules(engine)` — stale-pending / soft-delete / expiry
44
+ sweeps as `ScheduleDefinition[]` for arc's `schedulesPlugin` (2.21).
package/LICENSE ADDED
@@ -0,0 +1,75 @@
1
+ Classytic Source-Available License (Community & Commercial), Version 1.0
2
+
3
+ Copyright (c) 2026 Classytic LLC. All rights reserved.
4
+
5
+ This software is source-available, not open source. Please read this License
6
+ before using it.
7
+
8
+ 1. DEFINITIONS
9
+ "Software" means the contents of this package and any accompanying source
10
+ code, object code, or documentation, in any form.
11
+ "Licensor" means Classytic LLC, a limited liability company organized in the
12
+ United States.
13
+ "You" means the individual or legal entity exercising rights under this
14
+ License.
15
+ "Evaluation Use" means use for evaluation, development, testing,
16
+ experimentation, or internal non-commercial purposes only.
17
+ "Production Use" means any use of the Software that is live, commercial,
18
+ revenue-generating, or customer-facing, or any use other than Evaluation Use.
19
+ "Commercial License" means a separate written agreement executed with
20
+ Licensor that grants Production Use rights.
21
+
22
+ 2. OWNERSHIP
23
+ The Software is licensed, not sold. Licensor retains all right, title, and
24
+ interest in and to the Software, including all intellectual property rights.
25
+ All rights not expressly granted in this License are reserved by Licensor.
26
+
27
+ 3. EVALUATION GRANT
28
+ Subject to Your compliance with this License, Licensor grants You a
29
+ worldwide, royalty-free, non-exclusive, non-transferable, revocable license
30
+ to download, install, run, and modify the Software solely for Evaluation Use.
31
+
32
+ 4. PRODUCTION USE REQUIRES A COMMERCIAL LICENSE
33
+ Production Use of the Software is NOT permitted under this License and
34
+ requires a Commercial License from Licensor. A Commercial License may be
35
+ granted at Licensor's discretion, with or without a fee, and may confer
36
+ perpetual, irrevocable, and transferable Production Use rights as set out in
37
+ that agreement. Contact licensing@classytic.com to obtain one.
38
+
39
+ 5. RESTRICTIONS
40
+ You may not:
41
+ (a) make any Production Use of the Software without a Commercial License;
42
+ (b) redistribute, resell, rent, sublicense, or otherwise make the Software
43
+ (in whole or in part, original or modified) available to third parties as
44
+ a stand-alone product, framework, library, or hosted/managed service that
45
+ competes with the Software or with Licensor;
46
+ (c) remove, obscure, or alter any copyright, license, or attribution notice.
47
+
48
+ 6. FEEDBACK AND CONTRIBUTIONS
49
+ Any feedback, suggestions, or contributions You provide relating to the
50
+ Software may be used by Licensor for any purpose without obligation to You.
51
+
52
+ 7. TERMINATION
53
+ This License and the Evaluation Grant terminate automatically if You breach
54
+ any term. Upon termination You must cease all use of the Software and destroy
55
+ all copies in Your possession. Rights under a Commercial License are governed
56
+ by that agreement's own terms and survive termination of this License to the
57
+ extent stated there.
58
+
59
+ 8. DISCLAIMER OF WARRANTY
60
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
61
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
62
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
63
+
64
+ 9. LIMITATION OF LIABILITY
65
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL LICENSOR BE LIABLE
66
+ FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
67
+ TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
68
+ OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
69
+
70
+ 10. GOVERNING LAW
71
+ This License is governed by the laws of the State of Delaware, United States,
72
+ without regard to its conflict-of-laws principles. (Adjust to Your LLC's state
73
+ of formation.)
74
+
75
+ For commercial licensing, contact: licensing@classytic.com
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @spinekit/media
2
+
3
+ > Two-phase direct-to-storage uploads, content-hash dedup, and media registry — `@classytic/media-kit` as one composable Arc resource.
4
+
5
+ The module every host otherwise hand-rolls around a blob store. Bytes never
6
+ stream through your API server: `start-write` mints a presigned target (or a
7
+ multipart/resumable session for big files), the client uploads straight to
8
+ storage, and `complete-write` registers the asset **storage-verified** (the
9
+ kernel re-checks exists/stat/MIME/tenant-bound key — client claims are never
10
+ trusted). A client that sends a content hash (`@classytic/media-transform`'s
11
+ `sha256Hex`) gets the dedup handshake: on a tenant-scoped hash hit, **no
12
+ upload happens at all**.
13
+
14
+ Direct wire `create`/`update` are disabled — the verified verbs are the only
15
+ way bytes enter. `DELETE` respects the engine's soft-delete + storage cleanup.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ npm install @spinekit/media @classytic/media-kit @classytic/arc @classytic/mongokit mongoose
21
+ ```
22
+
23
+ Peers: `@classytic/arc >=2.21.0` · `@classytic/media-kit >=3.6.0` ·
24
+ `@classytic/mongokit >=3.21.0` · `mongoose >=9.4.1`.
25
+
26
+ ## Usage
27
+
28
+ ```ts
29
+ import { createApp } from '@classytic/arc/factory';
30
+ import { schedulesPlugin } from '@classytic/arc/plugins';
31
+ import { requireAuth, requireRoles } from '@classytic/arc/permissions';
32
+ import { createMedia } from '@classytic/media-kit';
33
+ import { createS3Provider } from '@classytic/media-kit/providers/s3';
34
+ import { createMediaModule, mediaMaintenanceSchedules } from '@spinekit/media';
35
+
36
+ const engine = await createMedia({ connection, driver: createS3Provider({ /* ... */ }) });
37
+
38
+ const app = await createApp({
39
+ modules: [
40
+ createMediaModule({
41
+ engine,
42
+ permissions: {
43
+ view: requireAuth(),
44
+ upload: requireAuth(),
45
+ manage: requireRoles(['admin']),
46
+ },
47
+ }),
48
+ ],
49
+ plugins: async (f) => {
50
+ await f.register(schedulesPlugin, {
51
+ // lock: createMongoLockAdapter({ connection }) — for multi-replica
52
+ schedules: mediaMaintenanceSchedules(engine),
53
+ });
54
+ },
55
+ });
56
+ ```
57
+
58
+ ## Surface
59
+
60
+ | Route | What |
61
+ |---|---|
62
+ | `POST /media/start-write` | Dedup short-circuit (`sha256`) → else presigned PUT → else multipart/resumable session (`multipart`/`partCount`/size ≥ threshold) |
63
+ | `POST /media/complete-write` | Confirm a presigned upload; stores client display hints (`thumbhash`, `dominantColor`, `width`, `height`) |
64
+ | `POST /media/sign-parts` | On-demand part URLs for an open multipart session |
65
+ | `POST /media/complete-multipart` | Assemble parts, register the asset |
66
+ | `POST /media/:id/action` `{ action: 'signed-url' }` | Time-boxed signed read URL (needs engine `signing` config) |
67
+ | `GET /media`, `GET /media/:id` | Adapter reads (QueryParser filters: status, mimeType, folder, visibility, hash, filename, tags) |
68
+ | `DELETE /media/:id` | Kernel delete (soft per engine config, storage cleanup) |
69
+
70
+ `mediaMaintenanceSchedules(engine)` returns `ScheduleDefinition[]` for arc's
71
+ `schedulesPlugin` — stale-pending (hourly), soft-delete purge (daily), and
72
+ expiry purge (hourly) sweeps; pass a `lock` for multi-replica leader safety.
73
+ Abandoned uploads that never reached `complete-write` leave unregistered
74
+ storage keys — keep bucket lifecycle rules on as belt-and-braces.
75
+
76
+ The engine is exported at `fastify.arc.modules.media`. Host seams (extra
77
+ routes/actions, cache, field rules) inject via `seams:` — arc 2.21
78
+ `mergeResourceConfig` semantics.
79
+
80
+ ## License
81
+
82
+ MIT © Classytic
@@ -0,0 +1,66 @@
1
+ import { MediaContext, MediaEngine } from "@classytic/media-kit";
2
+ //#region src/attachments.d.ts
3
+ interface BoundMediaRef {
4
+ sourceModel: string;
5
+ sourceId: string;
6
+ }
7
+ interface BoundMediaStorage<TTarget, TContext> {
8
+ upload(input: {
9
+ target: TTarget;
10
+ buffer: Buffer;
11
+ filename: string;
12
+ mimeType: string;
13
+ }, context: TContext): Promise<BoundMediaRef>;
14
+ startWrite(input: {
15
+ target: TTarget;
16
+ filename: string;
17
+ contentType: string;
18
+ size: number;
19
+ }, context: TContext): Promise<{
20
+ kind: 'presigned';
21
+ upload: {
22
+ uploadUrl: string;
23
+ key: string;
24
+ headers?: Record<string, string>;
25
+ };
26
+ }>;
27
+ completeWrite(input: {
28
+ target: TTarget;
29
+ key: string;
30
+ filename: string;
31
+ mimeType: string;
32
+ size: number;
33
+ }, context: TContext): Promise<BoundMediaRef>;
34
+ signedUrl(input: {
35
+ target: TTarget;
36
+ mediaRef: BoundMediaRef;
37
+ expiresIn: number;
38
+ }, context: TContext): Promise<string>;
39
+ discard(mediaRef: BoundMediaRef, context: TContext): Promise<void>;
40
+ listOrphans(input: {
41
+ olderThan: Date;
42
+ limit: number;
43
+ }): Promise<readonly {
44
+ mediaRef: BoundMediaRef;
45
+ organizationId?: string;
46
+ }[]>;
47
+ }
48
+ interface BoundMediaStorageOptions<TTarget, TContext> {
49
+ engine(): Pick<MediaEngine, 'models' | 'repositories'>;
50
+ folderFor(target: TTarget): string;
51
+ contextFor(context: TContext): MediaContext;
52
+ /** Folder roots owned by this attachment domain, without a trailing wildcard. */
53
+ orphanFolderPrefixes: readonly [string, ...string[]];
54
+ /** Storage visibility stamped at verified completion. Default `private`. */
55
+ visibility?: 'public' | 'private';
56
+ }
57
+ /**
58
+ * Universal verified-storage adapter for domain attachments.
59
+ *
60
+ * The consuming domain owns target authorization. This adapter owns every
61
+ * MediaKit concern: server-derived folders, presign/confirm, signed reads,
62
+ * compensation and orphan enumeration.
63
+ */
64
+ declare function createBoundMediaStorage<TTarget, TContext>(options: BoundMediaStorageOptions<TTarget, TContext>): BoundMediaStorage<TTarget, TContext>;
65
+ //#endregion
66
+ export { BoundMediaRef, BoundMediaStorage, BoundMediaStorageOptions, createBoundMediaStorage };
@@ -0,0 +1,118 @@
1
+ //#region src/attachments.ts
2
+ function escapeRegex(value) {
3
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
+ }
5
+ function assertOwnedModel(engine, mediaRef) {
6
+ if (mediaRef.sourceModel !== engine.models.Media.modelName) throw Object.assign(/* @__PURE__ */ new Error("Media reference belongs to a different storage model"), {
7
+ status: 422,
8
+ code: "media.attachment.model_mismatch"
9
+ });
10
+ }
11
+ /**
12
+ * Universal verified-storage adapter for domain attachments.
13
+ *
14
+ * The consuming domain owns target authorization. This adapter owns every
15
+ * MediaKit concern: server-derived folders, presign/confirm, signed reads,
16
+ * compensation and orphan enumeration.
17
+ */
18
+ function createBoundMediaStorage(options) {
19
+ const folderPattern = `^(?:${options.orphanFolderPrefixes.map((prefix) => escapeRegex(prefix.replace(/\/+$/, ""))).join("|")})/`;
20
+ return {
21
+ async upload({ target, buffer, filename, mimeType }, context) {
22
+ const engine = options.engine();
23
+ const folder = options.folderFor(target);
24
+ const mediaContext = options.contextFor(context);
25
+ const media = await engine.repositories.media.upload({
26
+ buffer,
27
+ filename,
28
+ mimeType,
29
+ folder
30
+ }, mediaContext);
31
+ if (media.folder !== folder) {
32
+ await engine.repositories.media.hardDelete(String(media._id), mediaContext);
33
+ throw Object.assign(/* @__PURE__ */ new Error("Uploaded media is outside its attachment folder"), {
34
+ status: 422,
35
+ code: "media.attachment.folder_mismatch"
36
+ });
37
+ }
38
+ return {
39
+ sourceModel: engine.models.Media.modelName,
40
+ sourceId: String(media._id)
41
+ };
42
+ },
43
+ async startWrite({ target, filename, contentType, size }, context) {
44
+ return {
45
+ kind: "presigned",
46
+ upload: await options.engine().repositories.media.getSignedUploadUrl(filename, contentType, {
47
+ folder: options.folderFor(target),
48
+ size
49
+ }, options.contextFor(context))
50
+ };
51
+ },
52
+ async completeWrite({ target, key, filename, mimeType, size }, context) {
53
+ const engine = options.engine();
54
+ const mediaContext = options.contextFor(context);
55
+ const folder = options.folderFor(target);
56
+ const media = await engine.repositories.media.confirmUpload({
57
+ key,
58
+ filename,
59
+ originalFilename: filename,
60
+ mimeType,
61
+ size,
62
+ folder,
63
+ hashStrategy: "etag",
64
+ visibility: options.visibility ?? "private"
65
+ }, mediaContext);
66
+ if (media.folder !== folder) {
67
+ await engine.repositories.media.hardDelete(String(media._id), mediaContext);
68
+ throw Object.assign(/* @__PURE__ */ new Error("Confirmed media is outside its attachment folder"), {
69
+ status: 422,
70
+ code: "media.attachment.folder_mismatch"
71
+ });
72
+ }
73
+ return {
74
+ sourceModel: engine.models.Media.modelName,
75
+ sourceId: String(media._id)
76
+ };
77
+ },
78
+ async signedUrl({ target, mediaRef, expiresIn }, context) {
79
+ const engine = options.engine();
80
+ assertOwnedModel(engine, mediaRef);
81
+ const mediaContext = options.contextFor(context);
82
+ const media = await engine.repositories.media.getById(mediaRef.sourceId, {
83
+ ...mediaContext.organizationId !== void 0 ? { organizationId: mediaContext.organizationId } : {},
84
+ ...mediaContext.actorId !== void 0 ? { actorId: mediaContext.actorId } : {},
85
+ ...mediaContext.session !== void 0 ? { session: mediaContext.session } : {}
86
+ });
87
+ if (!media || media.folder !== options.folderFor(target)) throw Object.assign(/* @__PURE__ */ new Error("Media is not attached to this target"), {
88
+ status: 403,
89
+ code: "media.attachment.access_denied"
90
+ });
91
+ return engine.repositories.media.getSignedAssetUrl(media, { expiresIn }, mediaContext);
92
+ },
93
+ async discard(mediaRef, context) {
94
+ const engine = options.engine();
95
+ assertOwnedModel(engine, mediaRef);
96
+ await engine.repositories.media.hardDelete(mediaRef.sourceId, options.contextFor(context));
97
+ },
98
+ async listOrphans({ olderThan, limit }) {
99
+ const engine = options.engine();
100
+ return (await engine.repositories.media.findAll({
101
+ folder: { $regex: folderPattern },
102
+ status: "ready",
103
+ createdAt: { $lt: olderThan }
104
+ }, {
105
+ limit,
106
+ sort: { createdAt: 1 }
107
+ })).map((media) => ({
108
+ mediaRef: {
109
+ sourceModel: engine.models.Media.modelName,
110
+ sourceId: String(media._id)
111
+ },
112
+ ...media.organizationId ? { organizationId: String(media.organizationId) } : {}
113
+ }));
114
+ }
115
+ };
116
+ }
117
+ //#endregion
118
+ export { createBoundMediaStorage };
@@ -0,0 +1,96 @@
1
+ import { AspectRatioPreset, MediaRuntime, MediaShape, MediaSigningConfig, SizeVariant, StorageDriver } from "@classytic/media-kit";
2
+ import { Connection } from "mongoose";
3
+ //#region src/config/media.config.d.ts
4
+ /** Cache windows, in seconds. `false` disables the cache plugin entirely. */
5
+ interface MediaCacheOptions {
6
+ /** Fresh window for reads (mongokit `staleTime`). Default 60. */
7
+ staleTime?: number;
8
+ /** Retention past stale (mongokit `gcTime`). Default 60. */
9
+ gcTime?: number;
10
+ /** Fresh window for `getById` specifically — hot and rarely mutated. Default 300. */
11
+ getByIdStaleTime?: number;
12
+ }
13
+ interface MediaShapeInput {
14
+ /**
15
+ * Namespaces this company's objects inside a bucket shared across companies.
16
+ * Prefixes the storage KEY only — the content-type map and folder browser
17
+ * stay on bare folder names.
18
+ */
19
+ keyPrefix?: string | undefined;
20
+ /** Folder assumed when an upload names none. Default `'general'`. */
21
+ defaultFolder?: string;
22
+ /**
23
+ * Branch-scope the library. Default `false` — see the docblock; `true` makes
24
+ * media-kit apply the multi-tenant plugin so reads filter by `organizationId`.
25
+ */
26
+ tenant?: boolean;
27
+ /** Disable image derivatives when originals are evidence and must remain byte-exact. */
28
+ processingEnabled?: boolean;
29
+ /** Disable cross-record content deduplication when each upload is a distinct record. */
30
+ deduplicationEnabled?: boolean;
31
+ /** Default visibility for new assets. Legal/document stores should use `private`. */
32
+ defaultVisibility?: 'public' | 'private';
33
+ /** Enable time-boxed URLs for private assets. The matching serve route is mounted by Spine Media. */
34
+ signing?: MediaSigningConfig;
35
+ /**
36
+ * Replace an already-registered `Media` model instead of refusing.
37
+ *
38
+ * Mongoose locks a schema on the first `model()` call, so mongokit REFUSES a
39
+ * second registration rather than silently dropping whatever this one adds.
40
+ * That is right for production and wrong for a suite that boots the app twice
41
+ * on one connection: the second boot failed with a model collision, several
42
+ * layers from the cause. Every other engine in the fleet already exposes this
43
+ * (`process.env.NODE_ENV === 'test'`); media was the one that did not, so a
44
+ * host had no way to express it at all.
45
+ */
46
+ forceRecreate?: boolean;
47
+ /** Per-deployment overrides of the tuned defaults. */
48
+ overrides?: {
49
+ allowedMimeTypes?: readonly string[];
50
+ maxSize?: number;
51
+ maxWidth?: number;
52
+ aspectRatios?: Record<string, AspectRatioPreset>;
53
+ sizes?: readonly SizeVariant[];
54
+ };
55
+ }
56
+ /**
57
+ * The SHAPE — describe-time only (STANDARDIZATION-PLAN §6.2).
58
+ *
59
+ * What the media collection LOOKS like: which file types, which folders, which
60
+ * derivatives. Nothing here opens a connection, reaches a bucket or builds a
61
+ * cache; those are runtime and travel through `createMediaRuntime`.
62
+ *
63
+ * The split is not cosmetic. `driver` and `plugins` used to sit in this object,
64
+ * so producing a config meant constructing a storage client — describing the
65
+ * catalogue of image sizes performed I/O.
66
+ */
67
+ declare function createMediaShape(input?: MediaShapeInput): MediaShape;
68
+ /**
69
+ * The RUNTIME — bind-time collaborators.
70
+ *
71
+ * The driver talks to object storage and the cache plugin allocates; both are
72
+ * live systems, so they are built when the Spine module calls `bind()`.
73
+ */
74
+ declare function createMediaRuntime(input: MediaRuntimeInput): MediaRuntime;
75
+ interface MediaRuntimeInput {
76
+ /** Resolved storage driver (see `resolveMediaProvider`). */
77
+ driver: StorageDriver;
78
+ /** Shared event transport, when the deployment has one. */
79
+ eventTransport?: MediaRuntime['eventTransport'];
80
+ /** Cache tuning, or `false` to run without the cache plugin. */
81
+ cache?: MediaCacheOptions | false;
82
+ }
83
+ interface MediaBlueprintSourceInput {
84
+ connection: Connection;
85
+ shape?: MediaShapeInput;
86
+ runtime: MediaRuntimeInput | (() => MediaRuntimeInput | Promise<MediaRuntimeInput>);
87
+ }
88
+ /** Build a blueprint source inside Spine Media's package lineage for file-linked hosts. */
89
+ declare function createMediaBlueprintSource(input: MediaBlueprintSourceInput): {
90
+ kind: 'blueprint';
91
+ blueprint: import("@classytic/media-kit").MediaBlueprint;
92
+ connection: Connection;
93
+ runtime: () => Promise<MediaRuntime>;
94
+ };
95
+ //#endregion
96
+ export { MediaBlueprintSourceInput, MediaCacheOptions, MediaRuntimeInput, MediaShapeInput, createMediaBlueprintSource, createMediaRuntime, createMediaShape };
@@ -0,0 +1,82 @@
1
+ import { ASPECT_RATIO_PRESETS, FOLDER_CONTENT_TYPE_MAP, IMAGE_SETTINGS, SIZE_VARIANTS } from "./media.defaults.mjs";
2
+ import { cachePlugin, createMemoryCache } from "@classytic/mongokit";
3
+ import { defineMedia } from "@classytic/media-kit";
4
+ //#region src/config/media.config.ts
5
+ /**
6
+ * The SHAPE — describe-time only (STANDARDIZATION-PLAN §6.2).
7
+ *
8
+ * What the media collection LOOKS like: which file types, which folders, which
9
+ * derivatives. Nothing here opens a connection, reaches a bucket or builds a
10
+ * cache; those are runtime and travel through `createMediaRuntime`.
11
+ *
12
+ * The split is not cosmetic. `driver` and `plugins` used to sit in this object,
13
+ * so producing a config meant constructing a storage client — describing the
14
+ * catalogue of image sizes performed I/O.
15
+ */
16
+ function createMediaShape(input = {}) {
17
+ const o = input.overrides ?? {};
18
+ return {
19
+ tenant: input.tenant ?? false,
20
+ ...input.forceRecreate !== void 0 ? { forceRecreate: input.forceRecreate } : {},
21
+ fileTypes: {
22
+ allowed: [...o.allowedMimeTypes ?? IMAGE_SETTINGS.allowedMimeTypes],
23
+ maxSize: o.maxSize ?? IMAGE_SETTINGS.maxSize
24
+ },
25
+ folders: {
26
+ defaultFolder: input.defaultFolder ?? "general",
27
+ ...input.keyPrefix ? { keyPrefix: input.keyPrefix } : {},
28
+ contentTypeMap: Object.fromEntries(Object.entries(FOLDER_CONTENT_TYPE_MAP).map(([folder, types]) => [folder, [...types]]))
29
+ },
30
+ processing: {
31
+ enabled: input.processingEnabled ?? true,
32
+ maxWidth: o.maxWidth ?? IMAGE_SETTINGS.defaultMaxWidth,
33
+ quality: IMAGE_SETTINGS.quality,
34
+ format: IMAGE_SETTINGS.format,
35
+ aspectRatios: o.aspectRatios ?? ASPECT_RATIO_PRESETS,
36
+ sizes: [...o.sizes ?? SIZE_VARIANTS],
37
+ generateAlt: IMAGE_SETTINGS.generateAlt,
38
+ thumbhash: true,
39
+ dominantColor: true,
40
+ smartSkip: true
41
+ },
42
+ deduplication: {
43
+ enabled: input.deduplicationEnabled ?? true,
44
+ returnExisting: input.deduplicationEnabled ?? true,
45
+ algorithm: "sha256"
46
+ },
47
+ visibility: { default: input.defaultVisibility ?? "public" },
48
+ ...input.signing ? { signing: input.signing } : {}
49
+ };
50
+ }
51
+ /**
52
+ * The RUNTIME — bind-time collaborators.
53
+ *
54
+ * The driver talks to object storage and the cache plugin allocates; both are
55
+ * live systems, so they are built when the Spine module calls `bind()`.
56
+ */
57
+ function createMediaRuntime(input) {
58
+ const cache = input.cache === false ? {} : input.cache ?? {};
59
+ return {
60
+ driver: input.driver,
61
+ ...input.eventTransport ? { eventTransport: input.eventTransport } : {},
62
+ plugins: input.cache === false ? [] : [cachePlugin({
63
+ adapter: createMemoryCache(),
64
+ defaults: {
65
+ staleTime: cache.staleTime ?? 60,
66
+ gcTime: cache.gcTime ?? 60
67
+ },
68
+ perOpDefaults: { getById: { staleTime: cache.getByIdStaleTime ?? 300 } }
69
+ })]
70
+ };
71
+ }
72
+ /** Build a blueprint source inside Spine Media's package lineage for file-linked hosts. */
73
+ function createMediaBlueprintSource(input) {
74
+ return {
75
+ kind: "blueprint",
76
+ blueprint: defineMedia(createMediaShape(input.shape)),
77
+ connection: input.connection,
78
+ runtime: async () => createMediaRuntime(typeof input.runtime === "function" ? await input.runtime() : input.runtime)
79
+ };
80
+ }
81
+ //#endregion
82
+ export { createMediaBlueprintSource, createMediaRuntime, createMediaShape };