@cosmicdrift/kumiko-framework 0.193.0 → 0.194.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 +3 -3
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/migrate-generator.test.ts +17 -0
- package/src/db/__tests__/money.test.ts +41 -5
- package/src/db/event-store-executor-read.ts +1 -1
- package/src/db/index.ts +1 -1
- package/src/db/migrate-generator.ts +13 -3
- package/src/db/money.ts +34 -4
- package/src/derivatives/__tests__/variant-key.test.ts +2 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +140 -0
- package/src/engine/__tests__/embedded-derived.test.ts +35 -0
- package/src/engine/__tests__/engine.test.ts +20 -0
- package/src/engine/__tests__/schema-builder.test.ts +93 -0
- package/src/engine/boot-validator/entity-handler.ts +64 -5
- package/src/engine/boot-validator/screens.ts +57 -36
- package/src/engine/embedded-derived.ts +9 -1
- package/src/engine/schema-builder.ts +33 -23
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
- package/src/errors/zod-bridge.ts +4 -9
- package/src/event-store/__tests__/perf.integration.test.ts +2 -11
- package/src/files/file-routes.ts +20 -6
- package/src/files/storage-tracking.ts +2 -1
- package/src/jobs/job-runner.ts +18 -9
- package/src/logging/__tests__/fallback-logger.test.ts +43 -0
- package/src/logging/utils.ts +14 -1
- package/src/observability/__tests__/metrics-handle.test.ts +30 -0
- package/src/observability/metrics-handle.ts +24 -12
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -5
- package/src/ui-types/index.ts +1 -0
|
@@ -724,10 +724,51 @@ describe("totalsMatch (fw#1839)", () => {
|
|
|
724
724
|
expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(true);
|
|
725
725
|
});
|
|
726
726
|
|
|
727
|
+
// kumiko-framework#1972: a round total (30 EUR = 3000 minor) still passes
|
|
728
|
+
// even if the major/minor conversion is silently skipped, because 30 and
|
|
729
|
+
// 3000 both "look" plausible under either interpretation. A crooked
|
|
730
|
+
// amount catches that a round-number fixture wouldn't.
|
|
731
|
+
test("accepts a crooked total (1234.56 EUR) whose minor-unit line sum matches exactly", () => {
|
|
732
|
+
const schema = buildInsertSchema(invoiceEntity());
|
|
733
|
+
const result = schema.safeParse({
|
|
734
|
+
total: { amount: 1234.56, currency: "EUR" },
|
|
735
|
+
lines: [{ amount: 100000 }, { amount: 23456 }],
|
|
736
|
+
});
|
|
737
|
+
expect(result.success).toBe(true);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// The exact shape of the reported solon incident: a genuinely wrong total
|
|
741
|
+
// (100.00 EUR entered as if it were already minor units, i.e. 1.00 EUR)
|
|
742
|
+
// must still be rejected — proves the refinement doesn't silently pass a
|
|
743
|
+
// mismatch through by accident.
|
|
744
|
+
test("rejects a total that was itself entered/scaled wrong (100x too small)", () => {
|
|
745
|
+
const schema = buildInsertSchema(invoiceEntity());
|
|
746
|
+
const result = schema.safeParse({
|
|
747
|
+
total: { amount: 1, currency: "EUR" }, // should have been 100
|
|
748
|
+
lines: [{ amount: 10000 }], // 100.00 EUR
|
|
749
|
+
});
|
|
750
|
+
expect(result.success).toBe(false);
|
|
751
|
+
if (!result.success) {
|
|
752
|
+
expect(result.error.issues.some((issue) => issue.path.join(".") === "lines")).toBe(true);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
727
756
|
test("update payload omitting the sibling total is not checked (nothing to compare against)", () => {
|
|
728
757
|
const schema = buildUpdateSchema(invoiceEntity());
|
|
729
758
|
expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(true);
|
|
730
759
|
});
|
|
760
|
+
|
|
761
|
+
test("rejects a sibling total tagged with a currency other than the entity's default, even when the raw minor-unit amounts match", () => {
|
|
762
|
+
const schema = buildInsertSchema(invoiceEntity());
|
|
763
|
+
const result = schema.safeParse({
|
|
764
|
+
total: { amount: 30, currency: "USD" },
|
|
765
|
+
lines: [{ amount: 1000 }, { amount: 2000 }],
|
|
766
|
+
});
|
|
767
|
+
expect(result.success).toBe(false);
|
|
768
|
+
if (!result.success) {
|
|
769
|
+
expect(result.error.issues.some((issue) => issue.path.join(".") === "total")).toBe(true);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
731
772
|
});
|
|
732
773
|
|
|
733
774
|
// --- kumiko-framework#1837: derived-cell server-side recomputation — the
|
|
@@ -959,6 +1000,58 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
|
|
|
959
1000
|
expect(row?.["amount"]).toBe(3);
|
|
960
1001
|
}
|
|
961
1002
|
});
|
|
1003
|
+
|
|
1004
|
+
test("a sum of scale-3 sources on a scale-2 decimal target is rounded, not rejected as over-scale", () => {
|
|
1005
|
+
const entity = createEntity({
|
|
1006
|
+
table: "Orders",
|
|
1007
|
+
fields: {
|
|
1008
|
+
lines: createEmbeddedListField(
|
|
1009
|
+
{
|
|
1010
|
+
a: { type: "decimal", scale: 3, required: true },
|
|
1011
|
+
b: { type: "decimal", scale: 3, required: true },
|
|
1012
|
+
c: { type: "decimal", scale: 3, required: true },
|
|
1013
|
+
total: { type: "decimal", scale: 2, required: false },
|
|
1014
|
+
},
|
|
1015
|
+
{ derived: { total: { op: "sum", from: ["a", "b", "c"] } } },
|
|
1016
|
+
),
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
const schema = buildInsertSchema(entity);
|
|
1020
|
+
// Each source is in-scale for its own scale-3 field, but the sum
|
|
1021
|
+
// (0.333) has 3 decimal digits — over-scale for the scale-2 target
|
|
1022
|
+
// without the rounding this PR added.
|
|
1023
|
+
const result = schema.safeParse({ lines: [{ a: 0.111, b: 0.111, c: 0.111 }] });
|
|
1024
|
+
expect(result.success).toBe(true);
|
|
1025
|
+
if (result.success) {
|
|
1026
|
+
const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
|
|
1027
|
+
expect(row?.["total"]).toBe(0.33);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test("a subtract landing on a money target rounds to whole minor units", () => {
|
|
1032
|
+
const entity = createEntity({
|
|
1033
|
+
table: "Orders",
|
|
1034
|
+
fields: {
|
|
1035
|
+
lines: createEmbeddedListField(
|
|
1036
|
+
{
|
|
1037
|
+
gross: { type: "decimal", scale: 2, required: true },
|
|
1038
|
+
refund: { type: "decimal", scale: 2, required: true },
|
|
1039
|
+
total: { type: "money", required: false },
|
|
1040
|
+
},
|
|
1041
|
+
{ derived: { total: { op: "subtract", from: ["gross", "refund"] } } },
|
|
1042
|
+
),
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
const schema = buildInsertSchema(entity);
|
|
1046
|
+
// 2400.58 - 93 = 2307.58 minor units — not representable by money's
|
|
1047
|
+
// integer constraint without rounding.
|
|
1048
|
+
const result = schema.safeParse({ lines: [{ gross: 2400.58, refund: 93 }] });
|
|
1049
|
+
expect(result.success).toBe(true);
|
|
1050
|
+
if (result.success) {
|
|
1051
|
+
const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
|
|
1052
|
+
expect(row?.["total"]).toBe(2308);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
962
1055
|
});
|
|
963
1056
|
|
|
964
1057
|
// --- Update schema (all partial) ---
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
1
|
+
import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
2
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
|
|
3
3
|
import { parseRefTarget } from "../parse-ref-target";
|
|
4
4
|
import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
|
|
@@ -557,6 +557,15 @@ function validateEmbeddedListBounds(
|
|
|
557
557
|
`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`,
|
|
558
558
|
);
|
|
559
559
|
}
|
|
560
|
+
// required:true means "at least one row" (schema-builder.ts falls back to
|
|
561
|
+
// minItems:1 for that); an explicit minItems:0 would silently win over
|
|
562
|
+
// that and let a required list submit empty — reject the contradiction
|
|
563
|
+
// instead of picking one side of it for the caller.
|
|
564
|
+
if (field.required === true && field.minItems === 0) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" sets required:true and minItems:0 — these contradict each other. Drop minItems (defaults to 1) or set required:false.`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
560
569
|
}
|
|
561
570
|
|
|
562
571
|
function validateEmbeddedDerivedCells(
|
|
@@ -693,6 +702,31 @@ function isPositiveInt(value: number): boolean {
|
|
|
693
702
|
return Number.isInteger(value) && value > 0;
|
|
694
703
|
}
|
|
695
704
|
|
|
705
|
+
// Renderers cap dimensions in practice (sharp refuses output above 0x1000000
|
|
706
|
+
// pixels); 8192 per edge is generously above any real thumbnail/preview use
|
|
707
|
+
// and keeps a boot-declared spec from becoming a memory-exhaustion vector.
|
|
708
|
+
const MAX_VARIANT_EDGE_PX = 8192;
|
|
709
|
+
// sharp's blur() accepts a sigma of 0.3..1000; anything above that throws at
|
|
710
|
+
// render time, months after boot, on the first request for that variant.
|
|
711
|
+
const MAX_BLUR_SIGMA = 1000;
|
|
712
|
+
|
|
713
|
+
function isValidBlurRegion(region: BlurRegion): boolean {
|
|
714
|
+
const { x, y, width, height } = region;
|
|
715
|
+
return (
|
|
716
|
+
Number.isFinite(x) &&
|
|
717
|
+
Number.isFinite(y) &&
|
|
718
|
+
Number.isFinite(width) &&
|
|
719
|
+
Number.isFinite(height) &&
|
|
720
|
+
x >= 0 &&
|
|
721
|
+
y >= 0 &&
|
|
722
|
+
width >= 0 &&
|
|
723
|
+
height >= 0 &&
|
|
724
|
+
x + width <= 1 &&
|
|
725
|
+
y + height <= 1
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// kumiko-lint-ignore complexity-budget blur/size bounds added for boot-time DoS guard
|
|
696
730
|
function assertValidVariantSpec(name: string, spec: VariantSpec, where: string): void {
|
|
697
731
|
if (!VARIANT_NAME_PATTERN.test(name)) {
|
|
698
732
|
throw new Error(
|
|
@@ -704,15 +738,23 @@ function assertValidVariantSpec(name: string, spec: VariantSpec, where: string):
|
|
|
704
738
|
}
|
|
705
739
|
if (
|
|
706
740
|
spec.size !== undefined &&
|
|
707
|
-
!(
|
|
741
|
+
!(
|
|
742
|
+
isPositiveInt(spec.size.width) &&
|
|
743
|
+
isPositiveInt(spec.size.height) &&
|
|
744
|
+
spec.size.width <= MAX_VARIANT_EDGE_PX &&
|
|
745
|
+
spec.size.height <= MAX_VARIANT_EDGE_PX
|
|
746
|
+
)
|
|
708
747
|
) {
|
|
709
748
|
throw new Error(
|
|
710
|
-
`Image variant "${name}" ${where} has
|
|
749
|
+
`Image variant "${name}" ${where} has an invalid "size" (${spec.size.width}x${spec.size.height}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px per edge.`,
|
|
711
750
|
);
|
|
712
751
|
}
|
|
713
|
-
if (
|
|
752
|
+
if (
|
|
753
|
+
spec.maxEdge !== undefined &&
|
|
754
|
+
!(isPositiveInt(spec.maxEdge) && spec.maxEdge <= MAX_VARIANT_EDGE_PX)
|
|
755
|
+
) {
|
|
714
756
|
throw new Error(
|
|
715
|
-
`Image variant "${name}" ${where} has
|
|
757
|
+
`Image variant "${name}" ${where} has an invalid "maxEdge" (${spec.maxEdge}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px.`,
|
|
716
758
|
);
|
|
717
759
|
}
|
|
718
760
|
if (spec.quality !== undefined && !(isPositiveInt(spec.quality) && spec.quality <= 100)) {
|
|
@@ -720,6 +762,23 @@ function assertValidVariantSpec(name: string, spec: VariantSpec, where: string):
|
|
|
720
762
|
`Image variant "${name}" ${where} has "quality" ${spec.quality} — must be an integer in 1..100.`,
|
|
721
763
|
);
|
|
722
764
|
}
|
|
765
|
+
if (
|
|
766
|
+
spec.blur !== undefined &&
|
|
767
|
+
!(Number.isFinite(spec.blur) && spec.blur > 0 && spec.blur <= MAX_BLUR_SIGMA)
|
|
768
|
+
) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`Image variant "${name}" ${where} has "blur" ${spec.blur} — must be a finite number in (0, ${MAX_BLUR_SIGMA}].`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (spec.blurRegions !== undefined) {
|
|
774
|
+
for (const region of spec.blurRegions) {
|
|
775
|
+
if (!isValidBlurRegion(region)) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`Image variant "${name}" ${where} has an invalid "blurRegions" entry ${JSON.stringify(region)} — x/y/width/height must be within 0..1 and x+width/y+height must not exceed 1.`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
723
782
|
}
|
|
724
783
|
|
|
725
784
|
// A bad spec is only visible when someone finally requests that variant —
|
|
@@ -8,7 +8,7 @@ import { rowMetaFieldNames } from "../../db/table-builder";
|
|
|
8
8
|
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
10
10
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
11
|
-
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
11
|
+
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
12
12
|
import type {
|
|
13
13
|
DashboardCustomPanel,
|
|
14
14
|
DashboardFilterDefinition,
|
|
@@ -27,7 +27,12 @@ import type {
|
|
|
27
27
|
// Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
|
|
28
28
|
// Can't import it directly — renderer depends on framework, not the reverse.
|
|
29
29
|
// Keep both lists in sync when a field type gains or loses an auto-wired widget.
|
|
30
|
-
const NO_WIDGET_FIELD_TYPES = new Set([
|
|
30
|
+
const NO_WIDGET_FIELD_TYPES: ReadonlySet<FieldDefinition["type"]> = new Set([
|
|
31
|
+
"jsonb",
|
|
32
|
+
"embedded",
|
|
33
|
+
"files",
|
|
34
|
+
"images",
|
|
35
|
+
]);
|
|
31
36
|
|
|
32
37
|
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
33
38
|
// entityEdit path (#1925) — a required field the user can never fill would
|
|
@@ -46,9 +51,7 @@ function validateNoWidgetRequiredField(
|
|
|
46
51
|
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
|
|
47
52
|
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
48
53
|
// grid widget (#1838) — only plain (non-list) embedded has no widget.
|
|
49
|
-
const isEmbeddedList =
|
|
50
|
-
fieldDef.type === "embedded" &&
|
|
51
|
-
(fieldDef as unknown as { multiple?: boolean }).multiple === true;
|
|
54
|
+
const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
|
|
52
55
|
// skip: list variant has a widget — not the no-widget case this guard targets.
|
|
53
56
|
if (isEmbeddedList) return;
|
|
54
57
|
// skip: already read-only by spec — no fillable widget needed regardless of type.
|
|
@@ -115,13 +118,9 @@ function validateRowActionNavigateParams(
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
// Wizard layouts (mode: "wizard")
|
|
119
|
-
// step
|
|
120
|
-
//
|
|
121
|
-
// as a broken step UI. Missing/blank titles are checked identically for
|
|
122
|
-
// both section kinds — EditExtensionSection.title is required by type,
|
|
123
|
-
// but that doesn't stop author code that circumvented the check from
|
|
124
|
-
// passing an empty string.
|
|
121
|
+
// Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
|
|
122
|
+
// untitled step would leave the progress indicator blank, so both fail at
|
|
123
|
+
// boot rather than as a broken step UI.
|
|
125
124
|
function validateWizardLayout(
|
|
126
125
|
featureName: string,
|
|
127
126
|
screenId: string,
|
|
@@ -276,6 +275,25 @@ function resolveScreenTargetQn(featureName: string, target: string): string {
|
|
|
276
275
|
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
277
276
|
}
|
|
278
277
|
|
|
278
|
+
function validateScreenNavTarget(
|
|
279
|
+
featureName: string,
|
|
280
|
+
screenId: string,
|
|
281
|
+
screenKind: string,
|
|
282
|
+
fieldName: string,
|
|
283
|
+
value: string,
|
|
284
|
+
allScreenQns: ReadonlySet<string>,
|
|
285
|
+
screens: FeatureDefinition["screens"],
|
|
286
|
+
): void {
|
|
287
|
+
const candidateQn = resolveScreenTargetQn(featureName, value);
|
|
288
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
|
|
291
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
292
|
+
`in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
279
297
|
export function validateScreens(
|
|
280
298
|
feature: FeatureDefinition,
|
|
281
299
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -554,26 +572,28 @@ export function validateScreens(
|
|
|
554
572
|
// (`<feature>:screen:<id>`) — der Renderer strippt letztere beim
|
|
555
573
|
// Navigieren auf die kurze ID (lastSegment), die der nav-Router
|
|
556
574
|
// app-weit auflöst (#1946).
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
575
|
+
validateScreenNavTarget(
|
|
576
|
+
feature.name,
|
|
577
|
+
screenId,
|
|
578
|
+
"actionForm",
|
|
579
|
+
"redirect",
|
|
580
|
+
screen.redirect,
|
|
581
|
+
allScreenQns,
|
|
582
|
+
feature.screens,
|
|
583
|
+
);
|
|
565
584
|
}
|
|
566
585
|
if (typeof screen.cancelTarget === "string") {
|
|
567
586
|
// Gleiche Regel wie redirect — `false` (kein Cancel-Button)
|
|
568
587
|
// braucht keine Validierung.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
validateScreenNavTarget(
|
|
589
|
+
feature.name,
|
|
590
|
+
screenId,
|
|
591
|
+
"actionForm",
|
|
592
|
+
"cancelTarget",
|
|
593
|
+
screen.cancelTarget,
|
|
594
|
+
allScreenQns,
|
|
595
|
+
feature.screens,
|
|
596
|
+
);
|
|
577
597
|
}
|
|
578
598
|
continue;
|
|
579
599
|
}
|
|
@@ -882,14 +902,15 @@ export function validateScreens(
|
|
|
882
902
|
if (screen.redirect !== undefined) {
|
|
883
903
|
// Same rule as actionForm's redirect: short screen-ID (same-feature)
|
|
884
904
|
// or a fully-qualified cross-feature QN (#1946).
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
905
|
+
validateScreenNavTarget(
|
|
906
|
+
feature.name,
|
|
907
|
+
screenId,
|
|
908
|
+
"entityEdit",
|
|
909
|
+
"redirect",
|
|
910
|
+
screen.redirect,
|
|
911
|
+
allScreenQns,
|
|
912
|
+
feature.screens,
|
|
913
|
+
);
|
|
893
914
|
}
|
|
894
915
|
}
|
|
895
916
|
}
|
|
@@ -35,7 +35,14 @@ export type DerivedCellRoundingTarget = {
|
|
|
35
35
|
* stays unit-agnostic for those). */
|
|
36
36
|
export function roundDerivedCellValue(value: number, target: DerivedCellRoundingTarget): number {
|
|
37
37
|
if (target.type === "money") return roundHalfAwayFromZero(value, 0);
|
|
38
|
-
|
|
38
|
+
// `scale` is required on the real decimal EmbeddedSubFieldDef (fields.ts:
|
|
39
|
+
// "no silent default that could truncate") — DerivedCellRoundingTarget
|
|
40
|
+
// only widens it to optional for the money/decimal split above. If it's
|
|
41
|
+
// ever missing anyway, pass the value through unrounded rather than
|
|
42
|
+
// guessing 0 decimals and truncating a value the caller never asked to round.
|
|
43
|
+
if (target.type === "decimal") {
|
|
44
|
+
return target.scale === undefined ? value : roundHalfAwayFromZero(value, target.scale);
|
|
45
|
+
}
|
|
39
46
|
return value;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -45,6 +52,7 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
|
|
|
45
52
|
// === 100.49999999999999) before rounding, so a value that's
|
|
46
53
|
// mathematically exactly at the half-step doesn't fall to the wrong side.
|
|
47
54
|
// ponytail: toPrecision(15) can shift by ±1 minor unit for values near Number.MAX_SAFE_INTEGER (2^53); fine for realistic money amounts.
|
|
55
|
+
// ponytail: also snaps values within ~1e-16 of a half-step up (e.g. roundHalfAwayFromZero(0.49999999999999994, 0) === 1) — deliberate, the float-noise case is far more common than a genuine near-half input.
|
|
48
56
|
const scaled = Number((Math.abs(value) * factor).toPrecision(15));
|
|
49
57
|
return (Math.sign(value) * Math.round(scaled)) / factor;
|
|
50
58
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { moneyPayloadToMinorUnits } from "../db/money";
|
|
3
3
|
import { isValidIanaTimeZone } from "../time";
|
|
4
4
|
import { assertUnreachable } from "../utils";
|
|
5
5
|
import { withDerivedCells } from "./embedded-derived";
|
|
@@ -295,14 +295,14 @@ export function fieldToZod(
|
|
|
295
295
|
// Runs via the same z.object().safeParse() call on both the client
|
|
296
296
|
// (form-controller's runValidate) and the server (write handler) — one
|
|
297
297
|
// mechanism, no separate client/server validation path to keep in sync.
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// here if multi-currency siblings become a real case.
|
|
298
|
+
// Row cells have no currency of their own (they're minor units in the
|
|
299
|
+
// entity's default currency); a sibling tagged with a different currency
|
|
300
|
+
// fails the check even if the raw minor-unit amounts happen to match.
|
|
302
301
|
//
|
|
303
302
|
// Known limitation: compares against rounded `derived` cells, i.e.
|
|
304
303
|
// "sum-of-rounded" not "round-of-sum" (kumiko-framework#1866). Follow-up
|
|
305
304
|
// for a computed, read-only sibling total: kumiko-framework#1873.
|
|
305
|
+
// kumiko-lint-ignore complexity-budget currency-equality check on sibling money payloads
|
|
306
306
|
function applyTotalsMatchRefinements(
|
|
307
307
|
entity: EntityDefinition,
|
|
308
308
|
schema: z.ZodObject<Record<string, z.ZodTypeAny>>,
|
|
@@ -313,30 +313,40 @@ function applyTotalsMatchRefinements(
|
|
|
313
313
|
const totalsMatch = field.totalsMatch;
|
|
314
314
|
result = result.superRefine((values, ctx) => {
|
|
315
315
|
for (const [subFieldName, siblingFieldName] of Object.entries(totalsMatch)) {
|
|
316
|
-
const
|
|
316
|
+
const rawRows = values[fieldName];
|
|
317
317
|
const siblingRaw = values[siblingFieldName];
|
|
318
318
|
// Not sent -> not checkable, not an error (partial update payloads).
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
319
|
+
if (rawRows === undefined || siblingRaw === undefined) continue;
|
|
320
|
+
// Not an array -> a different refinement already rejects the shape;
|
|
321
|
+
// this check isn't the right place to report it.
|
|
322
|
+
if (!Array.isArray(rawRows)) continue;
|
|
323
|
+
const siblingMinor = moneyPayloadToMinorUnits(siblingRaw);
|
|
324
|
+
if (siblingMinor === undefined) continue;
|
|
325
|
+
const siblingCurrency =
|
|
326
|
+
typeof siblingRaw === "object" && siblingRaw !== null && "currency" in siblingRaw
|
|
327
|
+
? (siblingRaw as { currency: unknown }).currency
|
|
328
|
+
: undefined;
|
|
329
|
+
const entityCurrency = entity.defaultCurrency ?? DEFAULT_CURRENCIES[0];
|
|
330
|
+
if (typeof siblingCurrency === "string" && siblingCurrency !== entityCurrency) {
|
|
331
|
+
ctx.addIssue({
|
|
332
|
+
code: "custom",
|
|
333
|
+
path: [siblingFieldName],
|
|
334
|
+
message: `"${siblingFieldName}" currency (${siblingCurrency}) does not match the entity's default currency (${entityCurrency}) that "${fieldName}" rows are summed in`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const sumMinor = rawRows.reduce((total: number, row: unknown) => {
|
|
339
|
+
const value =
|
|
340
|
+
typeof row === "object" && row !== null
|
|
341
|
+
? (row as Record<string, unknown>)[subFieldName]
|
|
328
342
|
: undefined;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
total + (typeof row[subFieldName] === "number" ? (row[subFieldName] as number) : 0),
|
|
333
|
-
0,
|
|
334
|
-
);
|
|
335
|
-
if (sumMinor !== toMinorUnits(siblingAmount)) {
|
|
343
|
+
return total + (typeof value === "number" ? value : 0);
|
|
344
|
+
}, 0);
|
|
345
|
+
if (sumMinor !== siblingMinor) {
|
|
336
346
|
ctx.addIssue({
|
|
337
347
|
code: "custom",
|
|
338
348
|
path: [fieldName],
|
|
339
|
-
message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${
|
|
349
|
+
message: `Sum of "${subFieldName}" across "${fieldName}" (${sumMinor}) does not match "${siblingFieldName}" (${siblingMinor})`,
|
|
340
350
|
});
|
|
341
351
|
}
|
|
342
352
|
}
|
|
@@ -66,8 +66,13 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
66
66
|
// The job-runner is built BEFORE the server, so it used to capture the raw
|
|
67
67
|
// caller context — without the per-tenant file-provider resolver buildServer
|
|
68
68
|
// wires onto it. An event-triggered job reaching for ctx.files then died in
|
|
69
|
-
// the worker while the identical code worked on the request path.
|
|
70
|
-
|
|
69
|
+
// the worker while the identical code worked on the request path. Recording
|
|
70
|
+
// `typeof ctx.files?.ref` (not the private `_fileProviderResolver` wire
|
|
71
|
+
// field) pins the observable symptom: a usable ctx.files handle. `ref()`
|
|
72
|
+
// itself stays lazy (file-handle.ts) — it never calls the resolver, which
|
|
73
|
+
// this feature's provider intentionally throws in, so the assertion below
|
|
74
|
+
// doesn't need a working provider to be meaningful.
|
|
75
|
+
const jobSawFilesRef: string[] = [];
|
|
71
76
|
|
|
72
77
|
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
73
78
|
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
@@ -84,7 +89,7 @@ const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
|
84
89
|
"read-bytes",
|
|
85
90
|
{ trigger: { on: requested.name }, runIn: "worker" },
|
|
86
91
|
async (_payload, ctx) => {
|
|
87
|
-
|
|
92
|
+
jobSawFilesRef.push(typeof ctx.files?.ref);
|
|
88
93
|
},
|
|
89
94
|
);
|
|
90
95
|
// Worker mode refuses to boot without a consumer to drain.
|
|
@@ -176,7 +181,7 @@ describe("entrypoint factories", () => {
|
|
|
176
181
|
queueNamePrefix: uniquePrefix("split-filejob"),
|
|
177
182
|
});
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
jobSawFilesRef.length = 0;
|
|
180
185
|
await worker.start();
|
|
181
186
|
try {
|
|
182
187
|
await worker.jobRunner.handleEvent(
|
|
@@ -184,8 +189,8 @@ describe("entrypoint factories", () => {
|
|
|
184
189
|
{ storageKey: "some/key.pdf" },
|
|
185
190
|
TestUsers.admin,
|
|
186
191
|
);
|
|
187
|
-
await waitForCondition(() =>
|
|
188
|
-
expect(
|
|
192
|
+
await waitForCondition(() => jobSawFilesRef.length > 0);
|
|
193
|
+
expect(jobSawFilesRef[0]).toBe("function");
|
|
189
194
|
} finally {
|
|
190
195
|
await worker.stop();
|
|
191
196
|
}
|
|
@@ -234,6 +239,32 @@ describe("entrypoint factories", () => {
|
|
|
234
239
|
}
|
|
235
240
|
});
|
|
236
241
|
|
|
242
|
+
test("All-in-one job-context also carries ctx.files, same fixture as the worker", async () => {
|
|
243
|
+
const registry = createRegistry([fileJobFeature]);
|
|
244
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
245
|
+
const entry = createAllInOneEntrypoint({
|
|
246
|
+
registry,
|
|
247
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
248
|
+
jwtSecret: JWT,
|
|
249
|
+
redisUrl,
|
|
250
|
+
queueNamePrefix: uniquePrefix("all-filejob"),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
jobSawFilesRef.length = 0;
|
|
254
|
+
await entry.start();
|
|
255
|
+
try {
|
|
256
|
+
await entry.jobRunner.handleEvent(
|
|
257
|
+
"file-job:event:bytes-requested",
|
|
258
|
+
{ storageKey: "some/key.pdf" },
|
|
259
|
+
TestUsers.admin,
|
|
260
|
+
);
|
|
261
|
+
await waitForCondition(() => jobSawFilesRef.length > 0);
|
|
262
|
+
expect(jobSawFilesRef[0]).toBe("function");
|
|
263
|
+
} finally {
|
|
264
|
+
await entry.stop();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
237
268
|
test("All-in-one entrypoint has both HTTP surface and background workers", async () => {
|
|
238
269
|
const registry = createRegistry([splitFeature]);
|
|
239
270
|
const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
|
package/src/errors/zod-bridge.ts
CHANGED
|
@@ -37,15 +37,10 @@ export function validationErrorFromZod(error: ZodError): ValidationError {
|
|
|
37
37
|
return new ValidationError({ fields }, { cause: error });
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// `
|
|
43
|
-
//
|
|
44
|
-
// same `errors.validation.custom` ("Invalid value.") key. A `superRefine`
|
|
45
|
-
// that needs its own key sets `params.i18nKey` on the issue; this is the one
|
|
46
|
-
// place that honors it. Keep in sync with the client-side mirror
|
|
47
|
-
// (packages/headless/src/form/zod-bridge.ts) — a superRefine can run on
|
|
48
|
-
// either side.
|
|
40
|
+
// `code: "custom"` is zod's catch-all for every superRefine/refine check;
|
|
41
|
+
// left mechanical it'd collapse onto one generic key, so a superRefine can
|
|
42
|
+
// set `params.i18nKey` to override it. Keep in sync with the client mirror
|
|
43
|
+
// (packages/headless/src/form/zod-bridge.ts).
|
|
49
44
|
function resolveI18nKey(issue: ZodIssue): string {
|
|
50
45
|
if (issue.code === "custom") {
|
|
51
46
|
const override = issue.params?.["i18nKey"];
|
|
@@ -12,17 +12,8 @@
|
|
|
12
12
|
// latency, single-node PG. Production deploys are slower; these numbers
|
|
13
13
|
// are the ceiling.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// suite, and flaked up to 3.4x under that (30-102ms vs the 25-30ms budgets
|
|
18
|
-
// above, #1940). Moved to its own `event-store-perf` CI job
|
|
19
|
-
// (test:integration:perf:eventstore) — but re-measuring against a fresh
|
|
20
|
-
// container per run (mirroring that job) showed the real cause wasn't job
|
|
21
|
-
// contention: p50 sits at 1-3ms in every run, and single-sample p99 spikes
|
|
22
|
-
// to 47-73ms even fully isolated on an idle machine, from cold-Postgres
|
|
23
|
-
// connection/cache warm-up. Gate switched from p99 (the single worst-of-200
|
|
24
|
-
// sample) to p95 (drops the top 10), which absorbs that cold-start outlier
|
|
25
|
-
// while still catching a real order-of-magnitude regression.
|
|
15
|
+
// Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
|
|
16
|
+
// #1940) — see that job's comment in ci.yml for why the gate is p95 not p99.
|
|
26
17
|
|
|
27
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
28
19
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
package/src/files/file-routes.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getUser } from "../api/auth-middleware";
|
|
|
4
4
|
import type { DbConnection } from "../db/connection";
|
|
5
5
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
|
-
import { createDerivativesContext, resolveFieldVariant } from "../derivatives";
|
|
7
|
+
import { createDerivativesContext, resolveFieldVariant, resolveRenderer } from "../derivatives";
|
|
8
8
|
import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
|
|
9
9
|
import { generateId } from "../utils";
|
|
10
10
|
import { buildContentDispositionHeader } from "./content-disposition";
|
|
@@ -68,7 +68,7 @@ const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"] as const;
|
|
|
68
68
|
|
|
69
69
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
70
70
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
71
|
-
// credential
|
|
71
|
+
// credential (see "Signed-URL default expiry" in core-files.md).
|
|
72
72
|
const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
73
73
|
|
|
74
74
|
// Default guard: on attached files, allow the uploader or a privileged role.
|
|
@@ -252,15 +252,23 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
252
252
|
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
253
253
|
|
|
254
254
|
// An unknown name and a denied file answer alike, so the route never
|
|
255
|
-
// confirms what exists.
|
|
256
|
-
// throws out of variant() as a 500, because a mount gap is a config
|
|
257
|
-
// error, not a missing variant.
|
|
255
|
+
// confirms what exists.
|
|
258
256
|
const registry = options.registry;
|
|
259
257
|
const spec = registry
|
|
260
258
|
? resolveFieldVariant(registry, fileRef.entityType, fileRef.fieldName, name)
|
|
261
259
|
: undefined;
|
|
262
260
|
if (!registry || !spec) return c.json({ error: "not_found" }, 404);
|
|
263
261
|
|
|
262
|
+
// The file's mimeType comes from the upload (`file.type`) — validateFile
|
|
263
|
+
// only checks it against the field's `accept` when the field declares
|
|
264
|
+
// one, so a field without `accept` lets a client upload anything and
|
|
265
|
+
// then request /variant/*. Without this check, derivatives.variant()
|
|
266
|
+
// throws for an unsupported source mimeType — an uncaught 500 whose
|
|
267
|
+
// message lists every registered renderer's extension name.
|
|
268
|
+
if (!resolveRenderer(registry, fileRef.mimeType)) {
|
|
269
|
+
return c.json({ error: "unsupported_media_type" }, 415);
|
|
270
|
+
}
|
|
271
|
+
|
|
264
272
|
// Built per request: createFileContext caches the resolved provider, so
|
|
265
273
|
// one shared across requests would serve tenant A's store to tenant B.
|
|
266
274
|
const files = createFileContext(() => options.resolveProvider(user.tenantId));
|
|
@@ -274,8 +282,14 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
274
282
|
const data = await files.ref(result.storageKey).read();
|
|
275
283
|
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
276
284
|
// size, and a variant is rendered for display, not for download.
|
|
285
|
+
// storageKey embeds specHash(spec), so the URL is content-stable until
|
|
286
|
+
// the spec changes — safe to cache. "private" because the response sits
|
|
287
|
+
// behind the tenant + guard gate above, not a shared CDN-cacheable asset.
|
|
277
288
|
return new Response(Buffer.from(data), {
|
|
278
|
-
headers: {
|
|
289
|
+
headers: {
|
|
290
|
+
"Content-Type": result.mimeType,
|
|
291
|
+
"Cache-Control": "private, max-age=31536000, immutable",
|
|
292
|
+
},
|
|
279
293
|
});
|
|
280
294
|
});
|
|
281
295
|
|