@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.
- package/package.json +4 -4
- package/src/__tests__/upgrade-cli.test.ts +28 -0
- package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
- package/src/api/auth-middleware.ts +30 -1
- package/src/api/auth-routes.ts +27 -2
- package/src/api/origin-middleware.ts +23 -20
- package/src/api/server.ts +10 -1
- package/src/changes.json +48 -0
- package/src/crypto/pg-kms-adapter.ts +2 -1
- package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
- package/src/db/api.ts +6 -0
- package/src/db/bun-provider.ts +8 -3
- package/src/db/connection.ts +3 -3
- package/src/db/eagerload.ts +2 -9
- package/src/db/index.ts +1 -1
- package/src/db/postgres-provider.ts +7 -4
- package/src/db/queries/event-consumer.ts +4 -3
- package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
- package/src/engine/boot-validator/transfer-graph.ts +2 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/parse-ref-target.ts +6 -0
- package/src/engine/registry-validate.ts +3 -12
- package/src/engine/types/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +128 -1
- package/src/files/__tests__/storage-tracking.test.ts +46 -0
- package/src/files/file-routes.ts +10 -3
- package/src/files/index.ts +9 -1
- package/src/files/storage-tracking.ts +160 -15
- package/src/files/types.ts +57 -2
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
- package/src/jobs/job-runner.ts +27 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
- package/src/pipeline/event-dispatcher-delivery.ts +22 -1
- package/src/pipeline/event-dispatcher.ts +25 -2
- package/src/pipeline/index.ts +4 -1
- package/src/pipeline/system-hooks.ts +6 -2
- package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
- package/src/stack/drain-event-consumers.ts +74 -0
- package/src/stack/index.ts +1 -0
- package/src/stack/redis.ts +3 -0
- package/src/stack/test-stack.ts +13 -4
- package/src/upgrade-cli.ts +17 -3
package/src/db/bun-provider.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/db/connection.ts
CHANGED
|
@@ -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:
|
|
37
|
-
|
|
38
|
-
},
|
|
37
|
+
close: () =>
|
|
38
|
+
client.end({ timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS }),
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
package/src/db/eagerload.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
22
|
-
|
|
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<
|
|
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
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
267
|
+
targetEntityName: parseRefTargetEntityName(field.entity),
|
|
277
268
|
labelField: field.labelField,
|
|
278
269
|
};
|
|
279
270
|
}
|
|
@@ -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 {
|
|
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
|
+
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
287
|
+
mimeType,
|
|
281
288
|
size: file.size,
|
|
282
289
|
storageKey,
|
|
283
290
|
},
|
package/src/files/index.ts
CHANGED
|
@@ -26,12 +26,18 @@ export {
|
|
|
26
26
|
isFileProviderPlugin,
|
|
27
27
|
makeFileProviderResolver,
|
|
28
28
|
} from "./provider-resolver";
|
|
29
|
-
export {
|
|
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,
|