@cosmicdrift/kumiko-framework 0.312.0 → 0.314.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.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/upgrade-cli.test.ts +28 -0
  3. package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
  4. package/src/api/auth-middleware.ts +30 -1
  5. package/src/api/auth-routes.ts +27 -2
  6. package/src/api/origin-middleware.ts +23 -20
  7. package/src/api/server.ts +10 -1
  8. package/src/changes.json +48 -0
  9. package/src/crypto/pg-kms-adapter.ts +2 -1
  10. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  11. package/src/db/api.ts +6 -0
  12. package/src/db/bun-provider.ts +8 -3
  13. package/src/db/connection.ts +3 -3
  14. package/src/db/eagerload.ts +2 -9
  15. package/src/db/index.ts +1 -1
  16. package/src/db/postgres-provider.ts +7 -4
  17. package/src/db/queries/event-consumer.ts +4 -3
  18. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  19. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  20. package/src/engine/index.ts +3 -0
  21. package/src/engine/parse-ref-target.ts +6 -0
  22. package/src/engine/registry-validate.ts +3 -12
  23. package/src/engine/types/index.ts +1 -0
  24. package/src/files/__tests__/files.integration.test.ts +128 -1
  25. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  26. package/src/files/file-routes.ts +10 -3
  27. package/src/files/index.ts +9 -1
  28. package/src/files/storage-tracking.ts +160 -15
  29. package/src/files/types.ts +57 -2
  30. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
  31. package/src/jobs/job-runner.ts +27 -0
  32. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  33. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
  34. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  35. package/src/pipeline/event-dispatcher.ts +25 -2
  36. package/src/pipeline/index.ts +4 -1
  37. package/src/pipeline/system-hooks.ts +6 -2
  38. package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
  39. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
  40. package/src/stack/drain-event-consumers.ts +74 -0
  41. package/src/stack/index.ts +1 -0
  42. package/src/stack/redis.ts +3 -0
  43. package/src/stack/test-stack.ts +13 -4
  44. package/src/upgrade-cli.ts +17 -3
@@ -8,7 +8,11 @@
8
8
  // Bun.SQL hat kein LISTEN — postgres-js-Peer für event-dispatcher.
9
9
 
10
10
  import postgres from "postgres";
11
- import type { DbConnectionOptions, DbPoolHandle } from "./api";
11
+ import {
12
+ type DbConnectionOptions,
13
+ type DbPoolHandle,
14
+ DEFAULT_DB_CLOSE_TIMEOUT_SECONDS,
15
+ } from "./api";
12
16
 
13
17
  export function createBunConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
14
18
  const bunOpts: { max?: number; idleTimeout?: number; connectionTimeout?: number } = {};
@@ -25,13 +29,14 @@ export function createBunConnection(url: string, options: DbConnectionOptions =
25
29
  pgOpts.connect_timeout = options.connectTimeoutSeconds;
26
30
  const listenClient = postgres(url, pgOpts);
27
31
 
32
+ const closeTimeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
28
33
  return {
29
34
  db,
30
35
  client: listenClient,
31
36
  listenClient,
32
37
  close: async () => {
33
- await db.end();
34
- await listenClient.end();
38
+ await db.end({ timeout: closeTimeout });
39
+ await listenClient.end({ timeout: closeTimeout });
35
40
  },
36
41
  };
37
42
  }
@@ -3,6 +3,7 @@
3
3
  import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
4
4
  import postgres from "postgres";
5
5
  import { readPositiveIntEnv } from "../utils/env-parse";
6
+ import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "./api";
6
7
 
7
8
  // Raw client types (postgres-js | Bun.SQL) — the name used across query/
8
9
  // event-store/pipeline call sites. The structural pool handle from ./api is
@@ -33,9 +34,8 @@ export function createDbConnection(
33
34
  return {
34
35
  db: client,
35
36
  client,
36
- close: async () => {
37
- await client.end();
38
- },
37
+ close: () =>
38
+ client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
39
39
  };
40
40
  }
41
41
 
@@ -26,6 +26,7 @@
26
26
  import { requestContext } from "../api/request-context";
27
27
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto";
28
28
  import { selectMany } from "../db/query";
29
+ import { parseRefTargetEntityName } from "../engine/parse-ref-target";
29
30
  import type { EntityDefinition, FieldDefinition, ReferenceFieldDef } from "../engine/types";
30
31
  import {
31
32
  collectEncryptedFieldNames,
@@ -67,21 +68,13 @@ function isReferenceField(field: FieldDefinition): field is ReferenceFieldDef {
67
68
  return field.type === "reference";
68
69
  }
69
70
 
70
- function parseRefEntity(raw: string): string {
71
- // Same-feature ("user") oder cross-feature ("users:user") — wir
72
- // brauchen nur den entity-name (Names sind global eindeutig in
73
- // entityMap). Der feature-prefix dient nur der Author-Klarheit.
74
- const idx = raw.indexOf(":");
75
- return idx < 0 ? raw : raw.slice(idx + 1);
76
- }
77
-
78
71
  export function collectReferenceFields(entity: EntityDefinition): readonly ReferenceFieldEntry[] {
79
72
  const out: ReferenceFieldEntry[] = [];
80
73
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
81
74
  if (!isReferenceField(fieldDef)) continue;
82
75
  out.push({
83
76
  fieldName,
84
- refEntityName: parseRefEntity(fieldDef.entity),
77
+ refEntityName: parseRefTargetEntityName(fieldDef.entity),
85
78
  multiple: fieldDef.multiple === true,
86
79
  });
87
80
  }
package/src/db/index.ts CHANGED
@@ -114,7 +114,7 @@ export {
114
114
  type PgErrorInfo,
115
115
  } from "./pg-error";
116
116
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
117
- export { executeRawQuery } from "./queries/raw-sql";
117
+ export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
118
118
  export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
119
119
  export {
120
120
  asRawClient,
@@ -3,7 +3,11 @@
3
3
  // `asRawClient(db)` wrappt .unsafe und .begin transparent.
4
4
 
5
5
  import postgres from "postgres";
6
- import type { DbConnectionOptions, DbPoolHandle } from "./api";
6
+ import {
7
+ type DbConnectionOptions,
8
+ type DbPoolHandle,
9
+ DEFAULT_DB_CLOSE_TIMEOUT_SECONDS,
10
+ } from "./api";
7
11
 
8
12
  export function createPgConnection(url: string, options: DbConnectionOptions = {}): DbPoolHandle {
9
13
  const pgOptions: Parameters<typeof postgres>[1] = {};
@@ -18,8 +22,7 @@ export function createPgConnection(url: string, options: DbConnectionOptions = {
18
22
  return {
19
23
  db: client,
20
24
  client,
21
- close: async () => {
22
- await client.end();
23
- },
25
+ close: () =>
26
+ client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
24
27
  };
25
28
  }
@@ -326,11 +326,12 @@ export async function selectConsumerForUpdate(
326
326
  db: AnyDb,
327
327
  name: string,
328
328
  instanceId: string,
329
- ): Promise<void> {
330
- await asRawClient(db).unsafe(
329
+ ): Promise<Record<string, unknown> | undefined> {
330
+ const rows = (await asRawClient(db).unsafe(
331
331
  `SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`,
332
332
  [name, instanceId],
333
- );
333
+ )) as ReadonlyArray<Record<string, unknown>>;
334
+ return rows[0];
334
335
  }
335
336
 
336
337
  export async function updateConsumerRebuildCursor(
@@ -115,6 +115,18 @@ describe("validateTransferGraph", () => {
115
115
  expect(() => validate(chain)).not.toThrow();
116
116
  });
117
117
 
118
+ // A feature-prefixed reference target ("<feature>:<entity>") must resolve
119
+ // to the same entity name as an unprefixed one, or a chain built entirely
120
+ // out of prefixed refs would silently never trip the depth limit.
121
+ test("counts a chain of feature-prefixed reference targets into the depth limit", () => {
122
+ const chain: Record<string, EntityDefinition> = { e0: entity({}) };
123
+ for (let i = 1; i <= MAX_TRANSFER_DEPTH + 1; i++) {
124
+ chain[`e${i}`] = entity({ references: { parentId: { entity: `graph:e${i - 1}` } } });
125
+ }
126
+
127
+ expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
128
+ });
129
+
118
130
  test("terminates on a reference cycle instead of reporting false depth", () => {
119
131
  expect(() =>
120
132
  validate({
@@ -1,3 +1,4 @@
1
+ import { parseRefTargetEntityName } from "../parse-ref-target";
1
2
  import type { EntityDefinition, FeatureDefinition } from "../types";
2
3
 
3
4
  // --- Transfer-graph boot validation (fw#3088) ---
@@ -39,7 +40,7 @@ function referenceTargets(entity: EntityDefinition): readonly string[] {
39
40
  for (const field of Object.values(entity.fields)) {
40
41
  if (field.type !== "reference") continue;
41
42
  if (field.multiple === true) continue;
42
- targets.push(field.entity);
43
+ targets.push(parseRefTargetEntityName(field.entity));
43
44
  }
44
45
  return targets;
45
46
  }
@@ -264,6 +264,8 @@ export {
264
264
  userCanReadFieldRow,
265
265
  userCanWriteFieldRow,
266
266
  } from "./ownership";
267
+ export type { ParsedRefTarget } from "./parse-ref-target";
268
+ export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target";
267
269
  export { buildPipelineSteps, stepsPipeline } from "./pipeline";
268
270
  export { defineApply, defineMspApply, setFields } from "./projection-helpers";
269
271
  export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
@@ -431,6 +433,7 @@ export type {
431
433
  JobDefinition,
432
434
  JobHandlerFn,
433
435
  JobTrigger,
436
+ JobTriggerEvent,
434
437
  JsonbFieldDef,
435
438
  KumikoEntityTypeMap,
436
439
  KumikoEventTypeMap,
@@ -20,3 +20,9 @@ export function parseRefTarget(raw: string, currentFeature: string): ParsedRefTa
20
20
  }
21
21
  return { featureName: raw.slice(0, idx), entityName: raw.slice(idx + 1) };
22
22
  }
23
+
24
+ // Entity names are globally unique in the registry, so callers that only
25
+ // need the entity (not its owning feature) can drop the currentFeature arg.
26
+ export function parseRefTargetEntityName(raw: string): string {
27
+ return parseRefTarget(raw, "").entityName;
28
+ }
@@ -2,6 +2,7 @@ import { configureEventPiiCatalog } from "../crypto/event-pii";
2
2
  import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
3
3
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
4
4
  import { resolveName } from "./handler-helpers";
5
+ import { parseRefTargetEntityName } from "./parse-ref-target";
5
6
  import type {
6
7
  RegistryState,
7
8
  SearchableReferenceField,
@@ -192,16 +193,6 @@ export function applyExtensionUsages(state: RegistryState): void {
192
193
  }
193
194
  }
194
195
 
195
- // "customer" (same-feature) or "users:customer" (cross-feature,
196
- // "<featureName>:<entityName>") — only the entity name matters here, names
197
- // are globally unique in entityMap. Local duplicate of the same parse done
198
- // in db/eagerload.ts's parseRefEntity — this loop has no feature context to
199
- // hand to engine/parse-ref-target.ts's parseRefTarget.
200
- function parseReferenceTargetEntityName(raw: string): string {
201
- const idx = raw.indexOf(":");
202
- return idx < 0 ? raw : raw.slice(idx + 1);
203
- }
204
-
205
196
  // fw#2660: labelField defaults to "id" (a UUID column) elsewhere, but a
206
197
  // searchable reference ILIKEs the label column — an implicit or explicit
207
198
  // "id" default would 500 on every search request. Fail at boot.
@@ -224,7 +215,7 @@ function buildSearchableReferenceField(
224
215
  `(array) reference fields — remove "multiple" or "searchable".`,
225
216
  );
226
217
  }
227
- const targetEntityName = parseReferenceTargetEntityName(field.entity);
218
+ const targetEntityName = parseRefTargetEntityName(field.entity);
228
219
  const targetEntity = entityMap.get(targetEntityName);
229
220
  if (targetEntity !== undefined) {
230
221
  if (
@@ -273,7 +264,7 @@ function buildSortableReferenceField(
273
264
  }
274
265
  return {
275
266
  fieldName,
276
- targetEntityName: parseReferenceTargetEntityName(field.entity),
267
+ targetEntityName: parseRefTargetEntityName(field.entity),
277
268
  labelField: field.labelField,
278
269
  };
279
270
  }
@@ -185,6 +185,7 @@ export type {
185
185
  HandlerRef,
186
186
  JobContext,
187
187
  JobRunnerRef,
188
+ JobTriggerEvent,
188
189
  MemberReader,
189
190
  NameOrRef,
190
191
  NotifyFactory,
@@ -8,6 +8,7 @@ import { buildServer } from "../../api/server";
8
8
  import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
9
9
  import {
10
10
  createEntity,
11
+ createFileField,
11
12
  createImageField,
12
13
  createRegistry,
13
14
  createTextField,
@@ -35,7 +36,13 @@ import type { FileRoutesOptions } from "../file-routes";
35
36
  import { createInMemoryFileProvider } from "../in-memory-provider";
36
37
  import { createLocalProvider } from "../local-provider";
37
38
  import type { FileStorageProvider, SignedUrlOptions } from "../types";
38
- import { parseMaxSize, sniffMimeType, validateFile, validateFileContent } from "../types";
39
+ import {
40
+ parseMaxSize,
41
+ resolveUploadMimeType,
42
+ sniffMimeType,
43
+ validateFile,
44
+ validateFileContent,
45
+ } from "../types";
39
46
 
40
47
  // UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
41
48
  // the query — the row just isn't there. Pre-v1 files-feature tests used
@@ -59,6 +66,7 @@ const testTenantEntity = createEntity({
59
66
  fields: {
60
67
  name: createTextField({ personal: false, reason: "test_fixture", required: true }),
61
68
  logo: createImageField({ maxSize: "2mb", accept: ["png", "jpg"] }),
69
+ importFile: createFileField({ maxSize: "2mb", accept: ["csv"] }),
62
70
  },
63
71
  });
64
72
 
@@ -296,6 +304,91 @@ describe("file validation", () => {
296
304
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
297
305
  );
298
306
  });
307
+
308
+ // --- .csv declared as application/vnd.ms-excel (Windows browsers) ---
309
+
310
+ test("validateFile accepts .csv declared as application/vnd.ms-excel when csv is in accept", () => {
311
+ expect(
312
+ validateFile(
313
+ { fileName: "data.csv", mimeType: "application/vnd.ms-excel", size: 100 },
314
+ { accept: ["csv"] },
315
+ ),
316
+ ).toBeNull();
317
+ });
318
+
319
+ test("validateFile does not extend the vnd.ms-excel alias to extensions other than csv", () => {
320
+ const error = validateFile(
321
+ { fileName: "photo.jpg", mimeType: "application/vnd.ms-excel", size: 100 },
322
+ { accept: ["jpg"] },
323
+ );
324
+ expectErrorIncludes(error, "mime_mismatch");
325
+ });
326
+
327
+ test("validateFile rejects a prototype-property mimeType instead of treating it as an alias match", () => {
328
+ const error = validateFile(
329
+ { fileName: "photo.jpg", mimeType: "constructor", size: 100 },
330
+ { accept: ["jpg"] },
331
+ );
332
+ expectErrorIncludes(error, "mime_mismatch");
333
+ });
334
+
335
+ describe("resolveUploadMimeType", () => {
336
+ const textCsvBytes = new TextEncoder().encode("name,age\nAda,36\n");
337
+
338
+ test("rewrites .csv + application/vnd.ms-excel to text/csv when bytes are text", () => {
339
+ expect(resolveUploadMimeType("data.csv", "application/vnd.ms-excel", textCsvBytes)).toEqual({
340
+ kind: "ok",
341
+ mimeType: "text/csv",
342
+ });
343
+ });
344
+
345
+ test("rewrites regardless of charset suffix or case", () => {
346
+ expect(
347
+ resolveUploadMimeType("data.csv", "Application/VND.MS-EXCEL; charset=utf-8", textCsvBytes),
348
+ ).toEqual({ kind: "ok", mimeType: "text/csv" });
349
+ });
350
+
351
+ test("rejects .csv + application/vnd.ms-excel when bytes are a real OLE binary", () => {
352
+ const result = resolveUploadMimeType("data.csv", "application/vnd.ms-excel", docBytes);
353
+ expect(result.kind).toBe("rejected");
354
+ if (result.kind === "rejected") {
355
+ expectErrorIncludes(result.error, "content_mismatch");
356
+ }
357
+ });
358
+
359
+ test("rejects .csv + application/vnd.ms-excel when bytes are a ZIP container (.xlsx)", () => {
360
+ const result = resolveUploadMimeType("data.csv", "application/vnd.ms-excel", docxBytes);
361
+ expect(result.kind).toBe("rejected");
362
+ if (result.kind === "rejected") {
363
+ expectErrorIncludes(result.error, "content_mismatch");
364
+ }
365
+ });
366
+
367
+ test("leaves .csv + text/plain unchanged", () => {
368
+ expect(resolveUploadMimeType("data.csv", "text/plain", textCsvBytes)).toEqual({
369
+ kind: "ok",
370
+ mimeType: "text/plain",
371
+ });
372
+ });
373
+
374
+ test("leaves .xls + application/vnd.ms-excel unchanged (alias is csv-only)", () => {
375
+ expect(resolveUploadMimeType("data.xls", "application/vnd.ms-excel", docBytes)).toEqual({
376
+ kind: "ok",
377
+ mimeType: "application/vnd.ms-excel",
378
+ });
379
+ });
380
+
381
+ test("a prototype-property extension or mimeType never resolves as an alias", () => {
382
+ expect(resolveUploadMimeType("x.constructor", "name", textCsvBytes)).toEqual({
383
+ kind: "ok",
384
+ mimeType: "name",
385
+ });
386
+ expect(resolveUploadMimeType("data.csv", "constructor", textCsvBytes)).toEqual({
387
+ kind: "ok",
388
+ mimeType: "constructor",
389
+ });
390
+ });
391
+ });
299
392
  });
300
393
 
301
394
  // --- Integration: Upload → Download → Delete via real HTTP API ---
@@ -523,6 +616,40 @@ describe("doc/docx upload is content-verified against magic bytes", () => {
523
616
  });
524
617
  });
525
618
 
619
+ // Bun's formData() derives File#type from the filename, so .csv never
620
+ // arrives as vnd.ms-excel here; the csv alias is covered by the unit tests above.
621
+ describe("vnd.ms-excel via HTTP (.xls)", () => {
622
+ const docBytes = new Uint8Array([
623
+ 0xd0,
624
+ 0xcf,
625
+ 0x11,
626
+ 0xe0,
627
+ 0xa1,
628
+ 0xb1,
629
+ 0x1a,
630
+ 0xe1,
631
+ ...Array(20).fill(0),
632
+ ]);
633
+
634
+ test("unattached .xls declared as application/vnd.ms-excel keeps its declared mimeType unchanged", async () => {
635
+ const res = await uploadFile(adminUser, "legacy.xls", docBytes, "application/vnd.ms-excel");
636
+ expect(res.status).toBe(201);
637
+ const body = await res.json();
638
+ expect(body.mimeType).toBe("application/vnd.ms-excel");
639
+ });
640
+
641
+ test("attached .xls upload is rejected by the importFile field's csv-only accept list", async () => {
642
+ const res = await uploadFile(adminUser, "legacy.xls", docBytes, "application/vnd.ms-excel", {
643
+ entityType: "tenant",
644
+ entityId: "1",
645
+ fieldName: "importFile",
646
+ });
647
+ expect(res.status).toBe(400);
648
+ const body = await res.json();
649
+ expect(body.error).toContain("invalid_file_type");
650
+ });
651
+ });
652
+
526
653
  // --- Cross-user access within a tenant (attached file owner-scope) ---
527
654
 
528
655
  describe("attached file owner-scope", () => {
@@ -0,0 +1,46 @@
1
+ // fileRefStorageDelta — the single source of truth the tenant-storage-usage
2
+ // MSP's apply handlers and transferTenantStorageUsage's replay both use to
3
+ // turn one fileRef event into a signed (bytes, files) delta.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { entityEventName } from "../../db";
7
+ import { fileRefStorageDelta } from "../storage-tracking";
8
+
9
+ const CREATED = entityEventName("fileRef", "created");
10
+ const DELETED = entityEventName("fileRef", "deleted");
11
+ const RESTORED = entityEventName("fileRef", "restored");
12
+
13
+ describe("fileRefStorageDelta", () => {
14
+ test("created: positive delta from payload.size", () => {
15
+ expect(fileRefStorageDelta({ type: CREATED, payload: { size: 42 } })).toEqual({
16
+ bytes: 42,
17
+ files: 1,
18
+ });
19
+ });
20
+
21
+ test("created: missing/non-numeric size reads as 0 bytes, still counts a file", () => {
22
+ expect(fileRefStorageDelta({ type: CREATED, payload: {} })).toEqual({ bytes: 0, files: 1 });
23
+ });
24
+
25
+ test("deleted: negative delta from payload.previous.size", () => {
26
+ expect(fileRefStorageDelta({ type: DELETED, payload: { previous: { size: 42 } } })).toEqual({
27
+ bytes: -42,
28
+ files: -1,
29
+ });
30
+ });
31
+
32
+ test("deleted: missing previous reads as 0 bytes, still counts -1 file", () => {
33
+ expect(fileRefStorageDelta({ type: DELETED, payload: {} })).toEqual({ bytes: 0, files: -1 });
34
+ });
35
+
36
+ test("restored: positive delta from payload.previous.size, symmetric to delete", () => {
37
+ expect(fileRefStorageDelta({ type: RESTORED, payload: { previous: { size: 42 } } })).toEqual({
38
+ bytes: 42,
39
+ files: 1,
40
+ });
41
+ });
42
+
43
+ test("an unrelated event type returns null", () => {
44
+ expect(fileRefStorageDelta({ type: "fileRef.renamed", payload: { size: 42 } })).toBeNull();
45
+ });
46
+ });
@@ -30,6 +30,7 @@ import type { FileProviderResolver } from "./provider-resolver";
30
30
  import {
31
31
  buildStorageKey,
32
32
  resolveServedContentType,
33
+ resolveUploadMimeType,
33
34
  validateFile,
34
35
  validateFileContent,
35
36
  } from "./types";
@@ -240,13 +241,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
240
241
  return c.json({ error: contentValidationError }, 400);
241
242
  }
242
243
 
244
+ const mimeTypeResolution = resolveUploadMimeType(file.name, file.type, data);
245
+ if (mimeTypeResolution.kind === "rejected") {
246
+ return c.json({ error: mimeTypeResolution.error }, 400);
247
+ }
248
+ const mimeType = mimeTypeResolution.mimeType;
249
+
243
250
  // Write binary FIRST (outside the tx — network/disk I/O doesn't belong
244
251
  // inside a PG connection's tx window). On DB-tx rollback below the bytes
245
252
  // are orphaned in the provider; cleanup-jobs sweep those later. Losing a
246
253
  // row on append-failure is acceptable; corrupting a committed row with a
247
254
  // missing binary is not.
248
255
  const storageProvider = await options.resolveProvider(user.tenantId);
249
- await storageProvider.write(storageKey, data, file.type);
256
+ await storageProvider.write(storageKey, data, mimeType);
250
257
 
251
258
  // Create via the standard entity executor: emits fileRef.created +
252
259
  // materialises the file_refs row in one tx (read-your-own-write). id is
@@ -260,7 +267,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
260
267
  id: fileRefId,
261
268
  storageKey,
262
269
  fileName: file.name,
263
- mimeType: file.type,
270
+ mimeType,
264
271
  size: file.size,
265
272
  entityType: entityType ?? null,
266
273
  entityId: entityId ?? null,
@@ -277,7 +284,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
277
284
  {
278
285
  id: fileRefId,
279
286
  fileName: file.name,
280
- mimeType: file.type,
287
+ mimeType,
281
288
  size: file.size,
282
289
  storageKey,
283
290
  },
@@ -26,12 +26,18 @@ export {
26
26
  isFileProviderPlugin,
27
27
  makeFileProviderResolver,
28
28
  } from "./provider-resolver";
29
- export { filesStorageTrackingFeature, tenantStorageUsageTable } from "./storage-tracking";
29
+ export {
30
+ fileRefStorageDelta,
31
+ filesStorageTrackingFeature,
32
+ tenantStorageUsageTable,
33
+ transferTenantStorageUsage,
34
+ } from "./storage-tracking";
30
35
  export type {
31
36
  FileMetadata,
32
37
  FileStorageProvider,
33
38
  FileValidationOptions,
34
39
  SignedUrlOptions,
40
+ UploadMimeTypeResolution,
35
41
  WriteStreamOptions,
36
42
  } from "./types";
37
43
  export {
@@ -39,6 +45,8 @@ export {
39
45
  buildStorageKey,
40
46
  normalizeMimeType,
41
47
  parseMaxSize,
48
+ resolveUploadMimeType,
49
+ sniffMimeType,
42
50
  tenantExportPrefix,
43
51
  tenantStoragePrefixes,
44
52
  validateFile,