@cosmicdrift/kumiko-framework 0.189.0 → 0.191.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.189.0",
3
+ "version": "0.191.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -95,6 +95,10 @@
95
95
  "types": "./src/files/index.ts",
96
96
  "default": "./src/files/index.ts"
97
97
  },
98
+ "./derivatives": {
99
+ "types": "./src/derivatives/index.ts",
100
+ "default": "./src/derivatives/index.ts"
101
+ },
98
102
  "./jobs": {
99
103
  "types": "./src/jobs/index.ts",
100
104
  "default": "./src/jobs/index.ts"
@@ -182,7 +186,7 @@
182
186
  "./package.json": "./package.json"
183
187
  },
184
188
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.189.0",
189
+ "@cosmicdrift/kumiko-types": "0.191.0",
186
190
  "bullmq": "^5.76.7",
187
191
  "bun-types": "^1.3.13",
188
192
  "hono": "^4.13.1",
@@ -198,7 +202,7 @@
198
202
  "zod": "^4.4.3"
199
203
  },
200
204
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.189.0",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.191.0",
202
206
  "bun-types": "^1.3.13",
203
207
  "pino-pretty": "^13.1.3"
204
208
  },
package/src/api/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
3
  import { createTenantDb } from "../db/tenant-db";
4
+ import { createDerivativesContext } from "../derivatives/derivatives-context";
4
5
  import { EXT_FILE_PROVIDER } from "../engine/extension-names";
5
6
  import { runsInLane } from "../engine/run-in";
6
7
  import { createAnonymousUser } from "../engine/system-user";
@@ -507,14 +508,23 @@ export function buildServer(options: ServerOptions): KumikoServer {
507
508
  // prefix before the first ":" owns the MSP. Used to reject
508
509
  // cross-feature ctx.appendEvent calls at emit-site.
509
510
  const mspOwner = msp.name.split(":")[0];
511
+ const mspFiles = fileProviderResolver
512
+ ? createFileContext(() => fileProviderResolver(event.tenantId))
513
+ : undefined;
510
514
  const applyCtx = createMultiStreamApplyContext({
511
515
  registry: options.registry,
512
516
  db: rawRunner,
513
517
  tenantId: event.tenantId,
514
518
  userId: event.metadata.userId,
515
519
  ...(mspOwner && { callerFeature: mspOwner }),
516
- ...(fileProviderResolver && {
517
- files: createFileContext(() => fileProviderResolver(event.tenantId)),
520
+ ...(mspFiles && { files: mspFiles }),
521
+ ...(mspFiles && {
522
+ derivatives: createDerivativesContext({
523
+ files: mspFiles,
524
+ registry: options.registry,
525
+ db: rawRunner,
526
+ tenantId: event.tenantId,
527
+ }),
518
528
  }),
519
529
  });
520
530
  await applyFn(event, rawRunner, applyCtx);
@@ -0,0 +1,162 @@
1
+ // Proves ctx.derivatives actually arrives on BOTH context-building paths
2
+ // this cut wires it into: the HTTP write-handler path (dispatch-shared.ts
3
+ // buildHandlerContext) and the job path (job-runner.ts handleJob). A unit
4
+ // test on createDerivativesContext alone can't catch a forgotten wiring
5
+ // point — this is that test.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
9
+ import { z } from "zod";
10
+ import { defineFeature, EXT_DERIVATIVE_RENDERER } from "../../engine";
11
+ import { InternalError, writeFailure } from "../../errors";
12
+ import { createFilesFeature } from "../../files/feature";
13
+ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
14
+ import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
15
+ import { buildMultipartBody, patchFileInstanceofForBunTest, waitFor } from "../../testing";
16
+
17
+ const fakeRender: DerivativeRendererPlugin["render"] = async () => new Uint8Array([9, 9, 9]);
18
+
19
+ const jobResults: Array<{ storageKey: string; mimeType: string; rendered: boolean }> = [];
20
+
21
+ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
22
+ r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, { onRegister: () => {} });
23
+ r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
24
+
25
+ r.writeHandler(
26
+ "make-variant",
27
+ z.object({ fileRefId: z.string() }),
28
+ async (event, ctx) => {
29
+ if (!ctx.derivatives) {
30
+ return writeFailure(
31
+ new InternalError({ message: "no ctx.derivatives on write-handler ctx" }),
32
+ );
33
+ }
34
+ const result = await ctx.derivatives.variant(
35
+ event.payload.fileRefId,
36
+ { maxEdge: 100 },
37
+ "thumb",
38
+ );
39
+ return { isSuccess: true as const, data: result };
40
+ },
41
+ { access: { openToAll: true } },
42
+ );
43
+
44
+ r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
45
+ const fileRefId = (payload as { fileRefId: string }).fileRefId;
46
+ if (!ctx.derivatives) {
47
+ throw new Error("no ctx.derivatives on job ctx");
48
+ }
49
+ const result = await ctx.derivatives.variant(fileRefId, { maxEdge: 200 }, "card");
50
+ jobResults.push(result);
51
+ });
52
+ });
53
+
54
+ let stack: TestStack;
55
+ const tenantId = testTenantId(1);
56
+ const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
57
+ const otherTenantId = testTenantId(2);
58
+ const otherTenantUser = createTestUser({ id: 10, tenantId: otherTenantId, roles: ["Admin"] });
59
+
60
+ beforeAll(async () => {
61
+ patchFileInstanceofForBunTest();
62
+ stack = await setupTestStack({
63
+ features: [createFilesFeature(), derivativesTestFeature],
64
+ files: { storageProvider: createInMemoryFileProvider() },
65
+ jobs: { consumerLane: "worker" },
66
+ });
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await stack.cleanup();
71
+ });
72
+
73
+ async function uploadFile(asUser = user): Promise<string> {
74
+ const token = await stack.jwt.sign(asUser);
75
+ const fd = new FormData();
76
+ fd.append("file", new File([Buffer.from([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }));
77
+ const { body, contentType } = await buildMultipartBody(fd);
78
+ const res = await stack.app.request("/api/files", {
79
+ method: "POST",
80
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
81
+ body,
82
+ });
83
+ expect(res.status).toBe(201);
84
+ const json = (await res.json()) as { id: string };
85
+ return json.id;
86
+ }
87
+
88
+ describe("ctx.derivatives wiring — HTTP write-handler path", () => {
89
+ test("a real write-handler sees ctx.derivatives and variant() returns a storage key", async () => {
90
+ const fileId = await uploadFile();
91
+
92
+ const result = await stack.http.writeOk<{
93
+ storageKey: string;
94
+ mimeType: string;
95
+ rendered: boolean;
96
+ }>("derivativestest:write:make-variant", { fileRefId: fileId }, user);
97
+
98
+ expect(result.storageKey).toContain("thumb-");
99
+ expect(result.mimeType).toBe("image/jpeg");
100
+ expect(result.rendered).toBe(true);
101
+ });
102
+ });
103
+
104
+ describe("ctx.derivatives wiring — job path", () => {
105
+ test("a job handler sees ctx.derivatives and variant() returns a storage key", async () => {
106
+ jobResults.length = 0;
107
+ const fileId = await uploadFile();
108
+
109
+ // handleJob resolves tenant scope from payload.tenantId (falls back to
110
+ // SYSTEM_TENANT_ID otherwise) — without it the derivatives lookup can't
111
+ // find the file uploaded under the test tenant.
112
+ await stack.jobRunner?.dispatch("derivativestest:job:record", { fileRefId: fileId, tenantId });
113
+
114
+ await waitFor(() => {
115
+ expect(jobResults.length).toBeGreaterThan(0);
116
+ });
117
+ expect(jobResults[0]?.storageKey).toContain("card-");
118
+ expect(jobResults[0]?.rendered).toBe(true);
119
+ });
120
+ });
121
+
122
+ // variant() filters on `id`, `tenantId`, and `isDeleted` — a unit test can't
123
+ // tell a real Postgres query from a stub that ignores those filters, so
124
+ // these three run against the real DB via setupTestStack.
125
+ describe("ctx.derivatives — the mandatory id/tenant/isDeleted filters", () => {
126
+ test("an unknown fileRefId throws", async () => {
127
+ const err = await stack.http.writeErr(
128
+ "derivativestest:write:make-variant",
129
+ { fileRefId: "00000000-0000-4000-8000-999999999999" },
130
+ user,
131
+ );
132
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
133
+ });
134
+
135
+ test("a soft-deleted fileRef throws", async () => {
136
+ const fileId = await uploadFile();
137
+ const token = await stack.jwt.sign(user);
138
+ const deleteRes = await stack.app.request(`/api/files/${fileId}`, {
139
+ method: "DELETE",
140
+ headers: { Authorization: `Bearer ${token}` },
141
+ });
142
+ expect(deleteRes.status).toBe(200);
143
+
144
+ const err = await stack.http.writeErr(
145
+ "derivativestest:write:make-variant",
146
+ { fileRefId: fileId },
147
+ user,
148
+ );
149
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
150
+ });
151
+
152
+ test("a fileRef uploaded under a different tenant is not resolvable", async () => {
153
+ const foreignFileId = await uploadFile(otherTenantUser);
154
+
155
+ const err = await stack.http.writeErr(
156
+ "derivativestest:write:make-variant",
157
+ { fileRefId: foreignFileId },
158
+ user,
159
+ );
160
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
161
+ });
162
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
3
+ import type { Registry } from "../../engine/types";
4
+ import { createFileContext } from "../../files/file-handle";
5
+ import { createInMemoryFileProvider } from "../../files/in-memory-provider";
6
+ import { createDerivativesContext, resolveRenderer } from "../derivatives-context";
7
+
8
+ const FILE_REF_ID = "11111111-1111-4111-8111-111111111111";
9
+ const TENANT_ID = "22222222-2222-4222-8222-222222222222";
10
+
11
+ // Minimal fake Registry — same pattern as provider-resolver.test.ts's
12
+ // fakeRegistry: only getExtensionUsages is exercised by resolveRenderer.
13
+ function fakeRegistry(usages: ReadonlyArray<{ entityName: string; options: unknown }>): Registry {
14
+ return {
15
+ getExtensionUsages: () => usages,
16
+ } as unknown as Registry;
17
+ }
18
+
19
+ function countingRenderer(): {
20
+ plugin: DerivativeRendererPlugin;
21
+ calls: () => number;
22
+ } {
23
+ let calls = 0;
24
+ const plugin: DerivativeRendererPlugin = {
25
+ render: async () => {
26
+ calls++;
27
+ return new Uint8Array([calls]);
28
+ },
29
+ };
30
+ return { plugin, calls: () => calls };
31
+ }
32
+
33
+ // bun-db's fetchOne(db, table, where) duck-types `db` as a TenantDb via
34
+ // tenantDbDelegate() and, when it matches, delegates straight to
35
+ // db.selectMany(...) instead of building raw SQL — so a fake satisfying that
36
+ // shape exercises variant()'s own logic without a live Postgres connection.
37
+ // selectMany evaluates `where` against the canned row instead of ignoring
38
+ // it: variant()'s two mandatory filters (tenantId, isDeleted) are real
39
+ // integration-test territory, but a fake that returns the row unconditionally
40
+ // would let those filters be deleted here without a single red test.
41
+ function fakeDbWithFileRef(row: { storageKey: string; mimeType: string }): unknown {
42
+ const canned: Record<string, unknown> = {
43
+ ...row,
44
+ id: FILE_REF_ID,
45
+ tenantId: TENANT_ID,
46
+ isDeleted: false,
47
+ };
48
+ return {
49
+ raw: { unsafe: () => {} },
50
+ tenantId: TENANT_ID,
51
+ selectMany: async (_table: unknown, where?: Record<string, unknown>) => {
52
+ const matches = Object.entries(where ?? {}).every(([key, value]) => canned[key] === value);
53
+ return matches ? [canned] : [];
54
+ },
55
+ fetchOne: async () => canned,
56
+ insertOne: async () => undefined,
57
+ updateMany: async () => [],
58
+ deleteMany: async () => {},
59
+ };
60
+ }
61
+
62
+ describe("resolveRenderer", () => {
63
+ test("exact MIME match wins over a wildcard", () => {
64
+ const { plugin: exactPlugin } = countingRenderer();
65
+ const { plugin: wildcardPlugin } = countingRenderer();
66
+ const registry = fakeRegistry([
67
+ { entityName: "image/*", options: wildcardPlugin },
68
+ { entityName: "image/png", options: exactPlugin },
69
+ ]);
70
+ expect(resolveRenderer(registry, "image/png")).toBe(exactPlugin);
71
+ });
72
+
73
+ test("falls back to a `<type>/*` wildcard when no exact match exists", () => {
74
+ const { plugin: wildcardPlugin } = countingRenderer();
75
+ const registry = fakeRegistry([{ entityName: "image/*", options: wildcardPlugin }]);
76
+ expect(resolveRenderer(registry, "image/jpeg")).toBe(wildcardPlugin);
77
+ });
78
+
79
+ test("no match returns undefined — caller throws with the known-list", () => {
80
+ const registry = fakeRegistry([]);
81
+ expect(resolveRenderer(registry, "application/pdf")).toBeUndefined();
82
+ });
83
+
84
+ test("a `; charset=` suffix on the source mimeType doesn't break resolution", () => {
85
+ const { plugin } = countingRenderer();
86
+ const registry = fakeRegistry([{ entityName: "image/jpeg", options: plugin }]);
87
+ expect(resolveRenderer(registry, "image/jpeg; charset=binary")).toBe(plugin);
88
+ });
89
+
90
+ test("a registered usage without a render() throws instead of falling back to the wildcard", () => {
91
+ const { plugin: wildcardPlugin } = countingRenderer();
92
+ const registry = fakeRegistry([
93
+ { entityName: "image/*", options: wildcardPlugin },
94
+ { entityName: "image/png", options: { renderer: () => {} } },
95
+ ]);
96
+ expect(() => resolveRenderer(registry, "image/png")).toThrow(/image\/png/);
97
+ });
98
+ });
99
+
100
+ describe("createDerivativesContext — variant()", () => {
101
+ async function setup(mimeType = "image/jpeg") {
102
+ const provider = createInMemoryFileProvider();
103
+ await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), mimeType);
104
+ const files = createFileContext(() => Promise.resolve(provider));
105
+ const { plugin, calls } = countingRenderer();
106
+ const registry = fakeRegistry([{ entityName: "image/*", options: plugin }]);
107
+ const db = fakeDbWithFileRef({ storageKey: "tenant/photo.jpg", mimeType });
108
+ const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
109
+ return { ctx, calls, provider };
110
+ }
111
+
112
+ test("derive-on-first-use: first call renders, second call with the same spec hits the cache", async () => {
113
+ const { ctx, calls } = await setup();
114
+ const spec = { maxEdge: 320 } as const;
115
+
116
+ const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
117
+ expect(first.rendered).toBe(true);
118
+ expect(calls()).toBe(1);
119
+
120
+ const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
121
+ expect(second.rendered).toBe(false);
122
+ expect(calls()).toBe(1);
123
+ expect(second.storageKey).toBe(first.storageKey);
124
+ });
125
+
126
+ test("a changed spec renders again at a different key", async () => {
127
+ const { ctx, calls } = await setup();
128
+ const first = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
129
+ const second = await ctx.variant(FILE_REF_ID, { maxEdge: 640 }, "thumb");
130
+
131
+ expect(calls()).toBe(2);
132
+ expect(second.rendered).toBe(true);
133
+ expect(second.storageKey).not.toBe(first.storageKey);
134
+ });
135
+
136
+ test("no renderer registered for the mimeType throws, naming the known patterns", async () => {
137
+ const provider = createInMemoryFileProvider();
138
+ await provider.write("tenant/doc.pdf", new Uint8Array([1]));
139
+ const files = createFileContext(() => Promise.resolve(provider));
140
+ const registry = fakeRegistry([{ entityName: "image/*", options: countingRenderer().plugin }]);
141
+ const db = fakeDbWithFileRef({ storageKey: "tenant/doc.pdf", mimeType: "application/pdf" });
142
+ const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
143
+
144
+ await expect(ctx.variant(FILE_REF_ID, {}, "thumb")).rejects.toThrow(/image\/\*/);
145
+ });
146
+
147
+ test("mimeType is consistent across a fresh render and a cache hit for the same spec", async () => {
148
+ const { ctx } = await setup();
149
+ const spec = { format: "webp" } as const;
150
+
151
+ const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
152
+ const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
153
+
154
+ expect(first.mimeType).toBe("image/webp");
155
+ expect(second.mimeType).toBe("image/webp");
156
+ });
157
+
158
+ test("the mimeType written to storage matches the mimeType returned to the caller", async () => {
159
+ const { ctx, provider } = await setup();
160
+ const spec = { format: "webp" } as const;
161
+
162
+ const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
163
+
164
+ expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
165
+ expect(result.mimeType).toBe("image/webp");
166
+ });
167
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { specHash, variantSuffix } from "../variant-key";
3
+
4
+ describe("specHash — key stability", () => {
5
+ test("key order doesn't matter", () => {
6
+ expect(specHash({ fit: "cover", maxEdge: 512 })).toBe(specHash({ maxEdge: 512, fit: "cover" }));
7
+ });
8
+
9
+ test("an explicit undefined is neutral, same as omitting the key", () => {
10
+ expect(specHash({ fit: "cover" })).toBe(specHash({ fit: "cover", blur: undefined }));
11
+ });
12
+
13
+ test("nested object keys are sorted too", () => {
14
+ expect(specHash({ size: { width: 1, height: 2 } })).toBe(
15
+ specHash({ size: { height: 2, width: 1 } }),
16
+ );
17
+ });
18
+
19
+ test("a changed value produces a different hash — the whole point of hashing the spec", () => {
20
+ expect(specHash({ maxEdge: 512 })).not.toBe(specHash({ maxEdge: 640 }));
21
+ });
22
+ });
23
+
24
+ describe("variantSuffix", () => {
25
+ test("the name is part of the suffix — same spec, different name diverges", () => {
26
+ const spec = { maxEdge: 512 } as const;
27
+ expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
28
+ });
29
+
30
+ test("shape is `<name>-<8 hex chars>`", () => {
31
+ expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{8}$/);
32
+ });
33
+
34
+ test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
35
+ expect(() => variantSuffix("../evil", {})).toThrow(/variant name/);
36
+ });
37
+
38
+ test("a name containing a slash is rejected", () => {
39
+ expect(() => variantSuffix("a/b", {})).toThrow(/variant name/);
40
+ });
41
+
42
+ test("an empty name is rejected", () => {
43
+ expect(() => variantSuffix("", {})).toThrow(/variant name/);
44
+ });
45
+
46
+ test("a name over 32 chars is rejected", () => {
47
+ expect(() => variantSuffix("a".repeat(40), {})).toThrow(/variant name/);
48
+ });
49
+
50
+ test("ordinary names pass through", () => {
51
+ expect(() => variantSuffix("thumb", {})).not.toThrow();
52
+ expect(() => variantSuffix("card-2x", {})).not.toThrow();
53
+ expect(() => variantSuffix("Hero", {})).not.toThrow();
54
+ });
55
+ });
@@ -0,0 +1,146 @@
1
+ import type {
2
+ DerivativeRendererPlugin,
3
+ DerivativesContext,
4
+ VariantSpec,
5
+ } from "@cosmicdrift/kumiko-types/derivatives-types";
6
+ import { type AnyDb, fetchOne } from "../bun-db/query";
7
+ import { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
8
+ import type { Registry, TenantId } from "../engine/types";
9
+ import type { FileContext } from "../files/file-handle";
10
+ import { fileRefsTable } from "../files/file-ref-table";
11
+ import { assertSafeStorageKey } from "../files/types";
12
+ import { variantSuffix } from "./variant-key";
13
+
14
+ export type DerivativesContextDeps = {
15
+ readonly files: FileContext;
16
+ readonly registry: Registry;
17
+ readonly db: AnyDb;
18
+ readonly tenantId: TenantId;
19
+ };
20
+
21
+ // extension-usage `options` is engine-payload (unknown) — structurally validate
22
+ // instead of casting blind, same pattern as isFileProviderPlugin.
23
+ function isDerivativeRendererPlugin(o: unknown): o is DerivativeRendererPlugin {
24
+ return typeof o === "object" && o !== null && "render" in o && typeof o.render === "function";
25
+ }
26
+
27
+ // Mirrors validateFile's mime-normalization: strip a `; charset=…` suffix,
28
+ // trim, lowercase — so a provider-supplied `image/jpeg; charset=binary`
29
+ // still resolves.
30
+ function normalizeMimeType(mimeType: string): string {
31
+ return mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
32
+ }
33
+
34
+ export function resolveRenderer(
35
+ registry: Registry,
36
+ mimeType: string,
37
+ ): DerivativeRendererPlugin | undefined {
38
+ const normalized = normalizeMimeType(mimeType);
39
+ const usages = registry.getExtensionUsages(EXT_DERIVATIVE_RENDERER);
40
+
41
+ const exact = usages.find((u) => u.entityName === normalized);
42
+ if (exact) {
43
+ if (!isDerivativeRendererPlugin(exact.options))
44
+ throw new Error(
45
+ `derivatives.resolveRenderer: "${exact.entityName}" registered without a render(input, spec, sourceMimeType) — extension options must be a DerivativeRendererPlugin.`,
46
+ );
47
+ return exact.options;
48
+ }
49
+
50
+ const wildcard = usages.find((u) => u.entityName === `${normalized.split("/")[0]}/*`);
51
+ if (wildcard) {
52
+ if (!isDerivativeRendererPlugin(wildcard.options))
53
+ throw new Error(
54
+ `derivatives.resolveRenderer: "${wildcard.entityName}" registered without a render(input, spec, sourceMimeType) — extension options must be a DerivativeRendererPlugin.`,
55
+ );
56
+ return wildcard.options;
57
+ }
58
+
59
+ return undefined;
60
+ }
61
+
62
+ type FileRefRow = {
63
+ readonly storageKey: string;
64
+ readonly mimeType: string;
65
+ };
66
+
67
+ function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
68
+ return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
69
+ }
70
+
71
+ // The spec — not the renderer — determines the output mimeType, so it's the
72
+ // single source of truth for both what gets written to storage and what the
73
+ // caller receives; a cache hit never runs the renderer, so there's nothing
74
+ // else to derive it from.
75
+ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
76
+ switch (spec.format) {
77
+ case "webp":
78
+ return "image/webp";
79
+ case "avif":
80
+ return "image/avif";
81
+ case "jpeg":
82
+ return "image/jpeg";
83
+ default:
84
+ return sourceMimeType;
85
+ }
86
+ }
87
+
88
+ export function createDerivativesContext(deps: DerivativesContextDeps): DerivativesContext {
89
+ return {
90
+ variant: async (fileRefId, spec, name) => {
91
+ const row = await fetchOne<Record<string, unknown>>(deps.db, fileRefsTable, {
92
+ id: fileRefId,
93
+ tenantId: deps.tenantId,
94
+ isDeleted: false,
95
+ });
96
+ if (!row) {
97
+ throw new Error(`derivatives.variant: no fileRef found for id "${fileRefId}"`);
98
+ }
99
+ if (!isFileRefRow(row)) {
100
+ throw new Error(
101
+ `derivatives.variant: fileRef "${fileRefId}" is missing storageKey/mimeType`,
102
+ );
103
+ }
104
+
105
+ const renderer = resolveRenderer(deps.registry, row.mimeType);
106
+ if (!renderer) {
107
+ const known =
108
+ deps.registry
109
+ .getExtensionUsages(EXT_DERIVATIVE_RENDERER)
110
+ .map((u) => u.entityName)
111
+ .join(", ") || "<none>";
112
+ throw new Error(
113
+ `derivatives.variant: no renderer registered for mimeType "${row.mimeType}". Known: ${known}.`,
114
+ );
115
+ }
116
+
117
+ const src = deps.files.ref(row.storageKey);
118
+ // ponytail: derived key keeps the source extension regardless of the
119
+ // spec's format — widen deriveKey if a storage backend ever routes on
120
+ // extension.
121
+ const target = src.derive(variantSuffix(name, spec));
122
+ // Belt-and-suspenders: variantSuffix already rejects an unsafe name,
123
+ // this catches a traversal segment reaching the key through any other
124
+ // path (e.g. a future deriveKey change).
125
+ assertSafeStorageKey(target.key);
126
+ const mimeType = outputMimeType(spec, row.mimeType);
127
+
128
+ if (await target.exists()) {
129
+ return {
130
+ storageKey: target.key,
131
+ mimeType,
132
+ rendered: false,
133
+ };
134
+ }
135
+
136
+ const original = await src.read();
137
+ const result = await renderer.render(original, spec, row.mimeType);
138
+ await target.write(result, mimeType);
139
+ return {
140
+ storageKey: target.key,
141
+ mimeType,
142
+ rendered: true,
143
+ };
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,3 @@
1
+ export type { DerivativesContextDeps } from "./derivatives-context";
2
+ export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
3
+ export { canonicalJson, specHash, variantSuffix } from "./variant-key";
@@ -0,0 +1,40 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
3
+
4
+ // Deterministic JSON: object keys sorted (recursively), `undefined` values
5
+ // dropped, arrays keep their order. Two specs that are semantically equal
6
+ // (same keys, different insertion order; explicit `undefined` vs. omitted)
7
+ // serialize identically.
8
+ export function canonicalJson(value: unknown): string {
9
+ if (Array.isArray(value)) {
10
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
11
+ }
12
+ if (value !== null && typeof value === "object") {
13
+ const entries = Object.entries(value as Record<string, unknown>)
14
+ .filter(([, v]) => v !== undefined)
15
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
16
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
17
+ }
18
+ return JSON.stringify(value) ?? "null";
19
+ }
20
+
21
+ // A key built from the variant name alone would keep serving stale pixels
22
+ // forever after a spec change; hashing the spec into the key means a
23
+ // changed spec is automatically a new URL.
24
+ export function specHash(spec: VariantSpec): string {
25
+ return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
26
+ }
27
+
28
+ const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
29
+
30
+ export function variantSuffix(name: string, spec: VariantSpec): string {
31
+ // `name` ends up in a storage key (see deriveKey) — an unvalidated value
32
+ // like "../../other-tenant/x" would escape the tenant prefix the key is
33
+ // built under.
34
+ if (!VARIANT_NAME_PATTERN.test(name)) {
35
+ throw new Error(
36
+ `derivatives: variant name must match ${VARIANT_NAME_PATTERN.source} (letters, digits, hyphens; max 32 chars), got "${name}"`,
37
+ );
38
+ }
39
+ return `${name}-${specHash(spec)}`;
40
+ }
@@ -87,6 +87,23 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
87
87
  // des file-foundation-Features MUSS diese Konstante mitziehen.
88
88
  export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
89
89
 
90
+ /**
91
+ * `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
92
+ * (file-derivatives).
93
+ *
94
+ * Renderer-Features (a future `derivatives-*` feature) register via
95
+ * `r.useExtension(EXT_DERIVATIVE_RENDERER, "<mimePattern>", { render })`,
96
+ * where `<mimePattern>` is an exact MIME type (`application/pdf`) or a
97
+ * type-wildcard (`image/*`). `ctx.derivatives.variant(...)` resolves the
98
+ * renderer for a FileRef's MIME type at call time — exact match first,
99
+ * wildcard second.
100
+ *
101
+ * No `r.extensionSelector` and no config key: unlike `fileProvider`, the
102
+ * resolution is deterministic from the MIME type — no tenant ever picks a
103
+ * different image library for the same content type.
104
+ */
105
+ export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
106
+
90
107
  /**
91
108
  * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
92
109
  * bei User-Forget oder Tenant-Destroy).
@@ -77,6 +77,7 @@ export type { EmitCtx } from "./event-helpers";
77
77
  export { emitEvent, typedPayload } from "./event-helpers";
78
78
  export type { KumikoExtensionName } from "./extension-names";
79
79
  export {
80
+ EXT_DERIVATIVE_RENDERER,
80
81
  EXT_EXTERNAL_RESOURCE,
81
82
  EXT_FILE_PROVIDER,
82
83
  EXT_INFRA_RESOURCE,
@@ -10,6 +10,11 @@ export type InMemoryFileProvider = FileStorageProvider & {
10
10
  // Test-only introspection: keys currently stored. Useful for assertions
11
11
  // like `expect(provider.keys()).toContain("tenant/foo.jpg")`.
12
12
  keys(): readonly string[];
13
+ // Test-only introspection: the mimeType a write()/writeStream() stored for
14
+ // a key — undefined mirrors an untracked write. FileStorageProvider has no
15
+ // read-back-metadata method (mimeType lives on the FileRef row in prod),
16
+ // so this is the only way a test can assert what actually landed in storage.
17
+ mimeTypeOf(key: string): string | undefined;
13
18
  // Test-only reset between cases. beforeEach-friendly.
14
19
  clear(): void;
15
20
  };
@@ -93,6 +98,10 @@ export function createInMemoryFileProvider(): InMemoryFileProvider {
93
98
  return Array.from(store.keys());
94
99
  },
95
100
 
101
+ mimeTypeOf(key) {
102
+ return store.get(key)?.mimeType;
103
+ },
104
+
96
105
  clear() {
97
106
  store.clear();
98
107
  },
@@ -3,6 +3,7 @@ import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
5
  import { createTenantDb } from "../db/tenant-db";
6
+ import { createDerivativesContext } from "../derivatives/derivatives-context";
6
7
  import { createSystemUser } from "../engine/system-user";
7
8
  import {
8
9
  type AppContext,
@@ -393,17 +394,33 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
393
394
  // at runtime the write-handler path never would (framework#1532).
394
395
  const notify = context._notifyFactory?.(jobSystemUser, tenantId);
395
396
  const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
397
+ // Shared by the config accessor and ctx.derivatives below — both need the
398
+ // same tenant-scoped db, and building it twice would let the two calls
399
+ // drift apart.
400
+ const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
396
401
  const config =
397
- context._configAccessorFactory && configDb
402
+ context._configAccessorFactory && tenantScopedDb
398
403
  ? context._configAccessorFactory({
399
404
  user: { id: jobSystemUser.id, tenantId },
400
- db: createTenantDb(configDb, tenantId, "system"),
405
+ db: tenantScopedDb,
401
406
  secrets: context.secrets,
402
407
  })
403
408
  : undefined;
409
+ // Mirror dispatch-shared.ts: ctx.derivatives needs files+db, same
410
+ // tenant-scoped db the config accessor above uses.
411
+ const derivatives =
412
+ files && tenantScopedDb
413
+ ? createDerivativesContext({
414
+ files,
415
+ registry,
416
+ db: tenantScopedDb,
417
+ tenantId,
418
+ })
419
+ : context.derivatives;
404
420
  const jobContext: AppContext = {
405
421
  ...context,
406
422
  files,
423
+ derivatives,
407
424
  ...(notify !== undefined && { notify }),
408
425
  ...(config !== undefined && { config }),
409
426
  // The runner owns the registry it resolved this job from — expose it so
@@ -4,6 +4,7 @@ import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
5
5
  import type { buildEntityTable } from "../db/table-builder";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
+ import { createDerivativesContext } from "../derivatives/derivatives-context";
7
8
  import type { defineTransitions } from "../engine/state-machine";
8
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
9
10
  import type {
@@ -209,6 +210,14 @@ export async function buildHandlerContext(
209
210
  // to a statically-injected context.files (tests).
210
211
  const fileResolver = context._fileProviderResolver;
211
212
  const files = fileResolver ? createFileContext(() => fileResolver(user.tenantId)) : context.files;
213
+ // ctx.derivatives builds on ctx.files (needs a FileContext to read the
214
+ // original + write the variant) plus db (to look up the FileRef row) — so
215
+ // it can only be constructed exactly when files+db both resolved; falls
216
+ // back to a statically-injected context.derivatives otherwise (tests).
217
+ const derivatives =
218
+ files && db
219
+ ? createDerivativesContext({ files, registry, db, tenantId: user.tenantId })
220
+ : context.derivatives;
212
221
 
213
222
  // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
214
223
  // resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
@@ -565,6 +574,7 @@ export async function buildHandlerContext(
565
574
  notify,
566
575
  ...(config && { config }),
567
576
  ...(files && { files }),
577
+ ...(derivatives && { derivatives }),
568
578
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
569
579
  // a handler actually starts building its write — bound here so entity
570
580
  // CRUD handlers (entity-handlers.ts) can forward it to the executor
@@ -1,3 +1,4 @@
1
+ import type { DerivativesContext } from "@cosmicdrift/kumiko-types/derivatives-types";
1
2
  import type { MultiStreamApplyContext } from "@cosmicdrift/kumiko-types/multi-stream-apply-context-types";
2
3
  import type { DbRunner } from "../db/connection";
3
4
  import type { AppendEventArgs, AppendEventFn, Registry, TenantId } from "../engine/types";
@@ -29,6 +30,8 @@ export type MultiStreamApplyContextDeps = {
29
30
  // Same FileContext the outer AppContext carries, passed through so
30
31
  // MSP applies can reach binaries without another wiring indirection.
31
32
  readonly files?: FileContext;
33
+ // Same DerivativesContext the outer AppContext carries — mirrors `files`.
34
+ readonly derivatives?: DerivativesContext;
32
35
  };
33
36
 
34
37
  export function createMultiStreamApplyContext(
@@ -36,6 +39,7 @@ export function createMultiStreamApplyContext(
36
39
  ): MultiStreamApplyContext {
37
40
  return {
38
41
  ...(deps.files ? { files: deps.files } : {}),
42
+ ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
39
43
  appendEvent: (async (args: AppendEventArgs) => {
40
44
  await appendDomainEventCore(
41
45
  {