@oneuptime/common 12.0.10 → 12.0.11

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 (81) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  9. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  10. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  11. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  12. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  13. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  14. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  15. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  16. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  17. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  18. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  19. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  20. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  21. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  22. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  23. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  24. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  25. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  26. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  27. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  28. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  29. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  30. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  31. package/Types/CustomField/CustomFieldType.ts +28 -0
  32. package/Types/Monitor/MonitorType.ts +11 -0
  33. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  34. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  35. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  36. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  37. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  38. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  39. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  40. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  41. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  42. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  43. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  45. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  51. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  52. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  53. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  54. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  55. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  56. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  58. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  59. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  60. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  61. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  62. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  63. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  64. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  68. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  70. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  72. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  74. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  75. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  76. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  77. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  78. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  79. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  80. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  81. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import {
3
+ SESSION_REPLAY_PRICE_IN_USD_PER_GB,
4
+ TELEMETRY_PRICE_IN_USD_PER_GB,
5
+ TELEMETRY_PRICE_RETENTION_IN_DAYS,
6
+ } from "../../../../Types/Billing/PayAsYouGoPricing";
7
+ import {
8
+ LogDataIngestMeteredPlan,
9
+ MetricsDataIngestMeteredPlan,
10
+ ProfilesDataIngestMeteredPlan,
11
+ SessionReplayDataIngestMeteredPlan,
12
+ TracesDataIngestMetredPlan,
13
+ } from "../../../../Server/Types/Billing/MeteredPlan/AllMeteredPlans";
14
+ import TelemetryMeteredPlan from "../../../../Server/Types/Billing/MeteredPlan/TelemetryMeteredPlan";
15
+
16
+ /*
17
+ * The dashboard now quotes the telemetry rate to Free plan users before they
18
+ * create an ingestion key. A quoted price that does not match the metered one
19
+ * is a promise we break on the invoice, so the two are pinned together here:
20
+ * the metered plans must charge exactly the advertised per-GB rate at the
21
+ * advertised retention.
22
+ */
23
+
24
+ /*
25
+ * Session replay rides on the same ingestion key at its own, much higher rate,
26
+ * so it is listed here with its own expected figure rather than left out - a
27
+ * plan missing from this table is a plan whose price the UI can misquote.
28
+ */
29
+ const TELEMETRY_PLANS: Array<{
30
+ name: string;
31
+ plan: TelemetryMeteredPlan;
32
+ pricePerGB: number;
33
+ }> = [
34
+ {
35
+ name: "logs",
36
+ plan: LogDataIngestMeteredPlan,
37
+ pricePerGB: TELEMETRY_PRICE_IN_USD_PER_GB,
38
+ },
39
+ {
40
+ name: "metrics",
41
+ plan: MetricsDataIngestMeteredPlan,
42
+ pricePerGB: TELEMETRY_PRICE_IN_USD_PER_GB,
43
+ },
44
+ {
45
+ name: "traces",
46
+ plan: TracesDataIngestMetredPlan,
47
+ pricePerGB: TELEMETRY_PRICE_IN_USD_PER_GB,
48
+ },
49
+ {
50
+ name: "profiles",
51
+ plan: ProfilesDataIngestMeteredPlan,
52
+ pricePerGB: TELEMETRY_PRICE_IN_USD_PER_GB,
53
+ },
54
+ {
55
+ name: "session replay",
56
+ plan: SessionReplayDataIngestMeteredPlan,
57
+ pricePerGB: SESSION_REPLAY_PRICE_IN_USD_PER_GB,
58
+ },
59
+ ];
60
+
61
+ describe("Telemetry metered plans charge the advertised pay as you go rate", () => {
62
+ it.each(TELEMETRY_PLANS)(
63
+ "$name: 1 GB retained for the advertised window costs the advertised per-GB price",
64
+ ({
65
+ plan,
66
+ pricePerGB,
67
+ }: {
68
+ plan: TelemetryMeteredPlan;
69
+ pricePerGB: number;
70
+ }) => {
71
+ expect(
72
+ plan.getTotalCostInUSD({
73
+ dataIngestedInGB: 1,
74
+ retentionInDays: TELEMETRY_PRICE_RETENTION_IN_DAYS,
75
+ }),
76
+ ).toBeCloseTo(pricePerGB, 10);
77
+ },
78
+ );
79
+
80
+ it("prices session replay well above the other pillars, as intended", () => {
81
+ /*
82
+ * Not incidental: the gap is why in-app copy cannot quote one rate for
83
+ * "data sent with this ingestion key".
84
+ */
85
+ expect(SESSION_REPLAY_PRICE_IN_USD_PER_GB).toBeGreaterThan(
86
+ TELEMETRY_PRICE_IN_USD_PER_GB,
87
+ );
88
+ expect(SESSION_REPLAY_PRICE_IN_USD_PER_GB).toBe(2);
89
+ });
90
+
91
+ it.each(TELEMETRY_PLANS)(
92
+ "$name: cost scales linearly with volume and retention",
93
+ ({
94
+ plan,
95
+ pricePerGB,
96
+ }: {
97
+ plan: TelemetryMeteredPlan;
98
+ pricePerGB: number;
99
+ }) => {
100
+ expect(
101
+ plan.getTotalCostInUSD({
102
+ dataIngestedInGB: 10,
103
+ retentionInDays: TELEMETRY_PRICE_RETENTION_IN_DAYS,
104
+ }),
105
+ ).toBeCloseTo(pricePerGB * 10, 10);
106
+
107
+ // Twice the advertised retention is twice the advertised price.
108
+ expect(
109
+ plan.getTotalCostInUSD({
110
+ dataIngestedInGB: 1,
111
+ retentionInDays: TELEMETRY_PRICE_RETENTION_IN_DAYS * 2,
112
+ }),
113
+ ).toBeCloseTo(pricePerGB * 2, 10);
114
+ },
115
+ );
116
+
117
+ it("charges nothing for no data", () => {
118
+ expect(
119
+ LogDataIngestMeteredPlan.getTotalCostInUSD({
120
+ dataIngestedInGB: 0,
121
+ retentionInDays: TELEMETRY_PRICE_RETENTION_IN_DAYS,
122
+ }),
123
+ ).toBe(0);
124
+ });
125
+ });
@@ -10,6 +10,7 @@ import EqualTo from "../../../../Types/BaseDatabase/EqualTo";
10
10
  import EqualToOrNull from "../../../../Types/BaseDatabase/EqualToOrNull";
11
11
  import GreaterThan from "../../../../Types/BaseDatabase/GreaterThan";
12
12
  import GreaterThanOrEqual from "../../../../Types/BaseDatabase/GreaterThanOrEqual";
13
+ import GreaterThanOrNull from "../../../../Types/BaseDatabase/GreaterThanOrNull";
13
14
  import InBetween from "../../../../Types/BaseDatabase/InBetween";
14
15
  import Includes from "../../../../Types/BaseDatabase/Includes";
15
16
  import IncludesAll from "../../../../Types/BaseDatabase/IncludesAll";
@@ -591,3 +592,510 @@ describe("buildJSONColumnQuery — parameter hygiene", () => {
591
592
  }
592
593
  });
593
594
  });
595
+
596
+ /*
597
+ * ---------------------------------------------------------------------------
598
+ * Ordering comparisons over a Date custom field
599
+ * ---------------------------------------------------------------------------
600
+ *
601
+ * A Date / DateTime custom field stores its value as an ISO-8601 UTC string,
602
+ * because jsonb has no date type and the column is shared by every field the
603
+ * project defines. "Renewal is after 2026-08-17" therefore reaches this module
604
+ * as `GreaterThan("2026-08-17T00:00:00.000Z")` — a string operand on an
605
+ * operator that, until compareOrdered existed, cast unconditionally to NUMERIC.
606
+ *
607
+ * The failure that produced was the quiet kind. `Number("2026-08-17T...")` is
608
+ * NaN, so the bound parameter rendered as `CAST(NaN AS NUMERIC)`, and
609
+ * numericExpression independently returns NULL for text that does not match the
610
+ * numeric regex. Every row was evaluated as `NULL > NaN` — never an error,
611
+ * never true — so the product showed a lit filter chip above an empty table and
612
+ * nothing anywhere said why. These tests exist so that shape cannot return.
613
+ */
614
+
615
+ type OrderedOperatorCase = {
616
+ name: string;
617
+ sqlOperator: string;
618
+ operatorFor: (value: string) => JSONObject[string];
619
+ };
620
+
621
+ const ORDERED_OPERATORS: Array<OrderedOperatorCase> = [
622
+ {
623
+ name: "GreaterThan",
624
+ sqlOperator: ">",
625
+ operatorFor: (value: string): JSONObject[string] => {
626
+ return new GreaterThan(value);
627
+ },
628
+ },
629
+ {
630
+ name: "GreaterThanOrEqual",
631
+ sqlOperator: ">=",
632
+ operatorFor: (value: string): JSONObject[string] => {
633
+ return new GreaterThanOrEqual(value);
634
+ },
635
+ },
636
+ {
637
+ name: "LessThan",
638
+ sqlOperator: "<",
639
+ operatorFor: (value: string): JSONObject[string] => {
640
+ return new LessThan(value);
641
+ },
642
+ },
643
+ {
644
+ name: "LessThanOrEqual",
645
+ sqlOperator: "<=",
646
+ operatorFor: (value: string): JSONObject[string] => {
647
+ return new LessThanOrEqual(value);
648
+ },
649
+ },
650
+ ];
651
+
652
+ const ISO_INSTANT: string = "2026-08-17T00:00:00.000Z";
653
+
654
+ describe("buildJSONColumnQuery — ordered comparison against an ISO-8601 date", () => {
655
+ test("GreaterThan reads the key as text and compares against TEXT", () => {
656
+ const query: JSONColumnQuery = build({
657
+ Renewal: new GreaterThan(ISO_INSTANT),
658
+ });
659
+
660
+ expect(query.toSql(COLUMN)).toContain(
661
+ `${COLUMN} ->> CAST(:p1 AS TEXT) > CAST(:p2 AS TEXT)`,
662
+ );
663
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
664
+ });
665
+
666
+ test("GreaterThanOrEqual reads the key as text and compares against TEXT", () => {
667
+ const query: JSONColumnQuery = build({
668
+ Renewal: new GreaterThanOrEqual(ISO_INSTANT),
669
+ });
670
+
671
+ expect(query.toSql(COLUMN)).toContain(
672
+ `${COLUMN} ->> CAST(:p1 AS TEXT) >= CAST(:p2 AS TEXT)`,
673
+ );
674
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
675
+ });
676
+
677
+ test("LessThan reads the key as text and compares against TEXT", () => {
678
+ const query: JSONColumnQuery = build({
679
+ Renewal: new LessThan(ISO_INSTANT),
680
+ });
681
+
682
+ expect(query.toSql(COLUMN)).toContain(
683
+ `${COLUMN} ->> CAST(:p1 AS TEXT) < CAST(:p2 AS TEXT)`,
684
+ );
685
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
686
+ });
687
+
688
+ test("LessThanOrEqual reads the key as text and compares against TEXT", () => {
689
+ const query: JSONColumnQuery = build({
690
+ Renewal: new LessThanOrEqual(ISO_INSTANT),
691
+ });
692
+
693
+ expect(query.toSql(COLUMN)).toContain(
694
+ `${COLUMN} ->> CAST(:p1 AS TEXT) <= CAST(:p2 AS TEXT)`,
695
+ );
696
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
697
+ });
698
+
699
+ test("a date-only value compares as text too", () => {
700
+ /*
701
+ * A Date (rather than DateTime) custom field stores "2026-08-17" with no
702
+ * time part. It is still not a number, so it still has to take the text
703
+ * arm — the Date and DateTime variants of the same field must not disagree
704
+ * about whether their filter works.
705
+ */
706
+ const query: JSONColumnQuery = build({
707
+ Renewal: new GreaterThan("2026-08-17"),
708
+ });
709
+
710
+ expect(query.toSql(COLUMN)).toContain("> CAST(:p2 AS TEXT)");
711
+ expect(query.parameters["p2"]).toBe("2026-08-17");
712
+ });
713
+ });
714
+
715
+ describe("buildJSONColumnQuery — the date-as-NaN regression", () => {
716
+ test.each(ORDERED_OPERATORS)(
717
+ "$name binds the ISO string itself, never NaN",
718
+ (operatorCase: OrderedOperatorCase) => {
719
+ /*
720
+ * The single most important assertion for this change. The old code path
721
+ * bound `Number(value)`, which for any ISO-8601 string is NaN, so the
722
+ * emitted predicate was `NULL > NaN`. Postgres is perfectly happy to
723
+ * evaluate that — it is NULL, not an error — so the filter returned zero
724
+ * rows on every table and no log line anywhere recorded a problem.
725
+ */
726
+ const query: JSONColumnQuery = build({
727
+ Renewal: operatorCase.operatorFor(ISO_INSTANT),
728
+ });
729
+
730
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
731
+ expect(Number.isNaN(query.parameters["p2"] as unknown as number)).toBe(
732
+ false,
733
+ );
734
+ },
735
+ );
736
+
737
+ test.each(ORDERED_OPERATORS)(
738
+ "$name does not emit the numeric CASE expression for a date",
739
+ (operatorCase: OrderedOperatorCase) => {
740
+ /*
741
+ * numericExpression is the other half of the old bug: it returns NULL for
742
+ * any text that fails the numeric regex, which every ISO-8601 string
743
+ * does. Seeing `CASE WHEN ... AS NUMERIC` in a date predicate means the
744
+ * left-hand side has gone back to being unconditionally NULL.
745
+ */
746
+ const sql: string = sqlOf({
747
+ Renewal: operatorCase.operatorFor(ISO_INSTANT),
748
+ });
749
+
750
+ expect(sql).not.toContain("CASE WHEN");
751
+ expect(sql).not.toContain("AS NUMERIC");
752
+ expect(sql).toContain(`${operatorCase.sqlOperator} CAST(:p2 AS TEXT)`);
753
+ },
754
+ );
755
+
756
+ test("no parameter anywhere in a date query is NaN", () => {
757
+ /*
758
+ * Swept across the whole bag rather than one name, so a future refactor
759
+ * that renumbers or reorders the binds cannot let a NaN back in unnoticed.
760
+ */
761
+ const query: JSONColumnQuery = build({
762
+ Opened: new GreaterThan(ISO_INSTANT),
763
+ Closed: new LessThan("2026-12-31T23:59:59.999Z"),
764
+ });
765
+
766
+ for (const parameterValue of Object.values(query.parameters)) {
767
+ expect(Number.isNaN(parameterValue as unknown as number)).toBe(false);
768
+ }
769
+ });
770
+ });
771
+
772
+ describe("buildJSONColumnQuery — ordered comparison against a number stays numeric", () => {
773
+ test.each(ORDERED_OPERATORS)(
774
+ "$name over a numeric string still casts to NUMERIC",
775
+ (operatorCase: OrderedOperatorCase) => {
776
+ /*
777
+ * The guard in the other direction. A Number custom field arrives as text
778
+ * from the form, so "42" is the normal shape of a numeric operand — if it
779
+ * fell through to the text arm, "9" would sort above "10" and every
780
+ * numeric threshold filter in the product would be quietly wrong.
781
+ */
782
+ const query: JSONColumnQuery = build({
783
+ Count: operatorCase.operatorFor("42"),
784
+ });
785
+
786
+ expect(query.toSql(COLUMN)).toContain("AS NUMERIC");
787
+ expect(query.parameters["p2"]).toBe(42);
788
+ },
789
+ );
790
+
791
+ test("a real number operand still casts to NUMERIC and binds as a number", () => {
792
+ const query: JSONColumnQuery = build({
793
+ Count: new GreaterThan(5),
794
+ });
795
+
796
+ expect(query.toSql(COLUMN)).toContain("CASE WHEN");
797
+ expect(query.toSql(COLUMN)).toContain(") > CAST(:p2 AS NUMERIC)");
798
+ expect(query.parameters["p2"]).toBe(5);
799
+ });
800
+
801
+ test('the "9 versus 10" case is the reason numbers must not go through text', () => {
802
+ /*
803
+ * Written as an assertion about JavaScript rather than about SQL because
804
+ * Postgres' text collation orders these the same way: as text "9" > "10",
805
+ * as numbers 9 < 10. Anyone tempted to simplify compareOrdered down to a
806
+ * single text comparison should have to delete this test first.
807
+ */
808
+ expect("9" > "10").toBe(true);
809
+ expect(Number("9") > Number("10")).toBe(false);
810
+
811
+ expect(sqlOf({ Count: new GreaterThan("9") })).toContain("AS NUMERIC");
812
+ });
813
+
814
+ test("a negative or fractional numeric string is still numeric", () => {
815
+ expect(build({ Count: new LessThan("-3.5") }).parameters["p2"]).toBe(-3.5);
816
+ expect(build({ Count: new GreaterThan("0.25") }).parameters["p2"]).toBe(
817
+ 0.25,
818
+ );
819
+ expect(sqlOf({ Count: new LessThan("-3.5") })).toContain("AS NUMERIC");
820
+ });
821
+
822
+ test("whitespace around a numeric string does not push it to the text arm", () => {
823
+ /*
824
+ * isNumericValue trims before Number(), and NUMERIC_TEXT_REGEX allows
825
+ * surrounding whitespace on the stored side, so both halves of the
826
+ * comparison agree that " 7 " is seven. If only one of them trimmed, the
827
+ * operand and the column would be compared under different rules.
828
+ */
829
+ const query: JSONColumnQuery = build({ Count: new GreaterThan(" 7 ") });
830
+
831
+ expect(query.toSql(COLUMN)).toContain("AS NUMERIC");
832
+ expect(query.parameters["p2"]).toBe(7);
833
+ });
834
+ });
835
+
836
+ describe("buildJSONColumnQuery — InBetween keeps its existing branch", () => {
837
+ test("two ISO dates compare as text on both bounds", () => {
838
+ /*
839
+ * InBetween has always chosen text for non-numeric bounds; this pins it so
840
+ * that the four operators which just joined it cannot drift apart from it
841
+ * again. A date range facet and a date threshold facet over the same field
842
+ * must agree about what "after" means.
843
+ */
844
+ const query: JSONColumnQuery = build({
845
+ Window: new InBetween(ISO_INSTANT, "2026-09-01T00:00:00.000Z"),
846
+ });
847
+ const sql: string = query.toSql(COLUMN);
848
+
849
+ expect(sql).toContain(">= CAST(:p2 AS TEXT)");
850
+ expect(sql).toContain("<= CAST(:p3 AS TEXT)");
851
+ expect(sql).not.toContain("AS NUMERIC");
852
+ expect(query.parameters["p2"]).toBe(ISO_INSTANT);
853
+ expect(query.parameters["p3"]).toBe("2026-09-01T00:00:00.000Z");
854
+ });
855
+
856
+ test("a numeric start with a date end falls to text", () => {
857
+ /*
858
+ * The branch is `isNumericValue(start) && isNumericValue(end)`, so one
859
+ * non-numeric bound demotes the whole range. That is the safe direction:
860
+ * casting a date bound to NUMERIC reproduces the NaN bug, whereas comparing
861
+ * a number as text merely mis-sorts a range nothing in the product builds.
862
+ */
863
+ const query: JSONColumnQuery = build({
864
+ Window: new InBetween(1 as unknown as string, "2026-01-01"),
865
+ });
866
+
867
+ expect(query.toSql(COLUMN)).not.toContain("AS NUMERIC");
868
+ expect(query.parameters["p2"]).toBe("1");
869
+ expect(query.parameters["p3"]).toBe("2026-01-01");
870
+ });
871
+
872
+ test("a date start with a numeric end falls to text as well", () => {
873
+ const query: JSONColumnQuery = build({
874
+ Window: new InBetween("2026-01-01", 10 as unknown as string),
875
+ });
876
+
877
+ expect(query.toSql(COLUMN)).not.toContain("AS NUMERIC");
878
+ expect(query.parameters["p2"]).toBe("2026-01-01");
879
+ expect(query.parameters["p3"]).toBe("10");
880
+ });
881
+
882
+ test("two numeric bounds stay numeric", () => {
883
+ const query: JSONColumnQuery = build({ Count: new InBetween(1, 10) });
884
+
885
+ expect(query.toSql(COLUMN)).toContain("AS NUMERIC");
886
+ expect(query.parameters["p2"]).toBe(1);
887
+ expect(query.parameters["p3"]).toBe(10);
888
+ });
889
+ });
890
+
891
+ describe("buildJSONColumnQuery — a Date instance reduces to its ISO string", () => {
892
+ test("GreaterThan(Date) binds the exact ISO-8601 instant", () => {
893
+ /*
894
+ * toScalar used to fall through to String(value) for a Date, which yields
895
+ * "Sun Aug 17 2026 00:00:00 GMT+0000 (Coordinated Universal Time)". Text
896
+ * comparison over that sorts by weekday name — "Fri" before "Mon" before
897
+ * "Sat" — which is not wrong in an obvious way, it is wrong in a way that
898
+ * looks like a plausible result set.
899
+ */
900
+ const query: JSONColumnQuery = build({
901
+ Renewal: new GreaterThan(new Date(ISO_INSTANT)),
902
+ });
903
+
904
+ expect(query.parameters["p2"]).toBe("2026-08-17T00:00:00.000Z");
905
+ });
906
+
907
+ test("LessThan(Date) binds the exact ISO-8601 instant", () => {
908
+ const query: JSONColumnQuery = build({
909
+ Renewal: new LessThan(new Date("2026-12-31T23:59:59.999Z")),
910
+ });
911
+
912
+ expect(query.parameters["p2"]).toBe("2026-12-31T23:59:59.999Z");
913
+ });
914
+
915
+ test("no locale-formatted date text reaches the parameter bag", () => {
916
+ const query: JSONColumnQuery = build({
917
+ Renewal: new GreaterThanOrEqual(new Date(ISO_INSTANT)),
918
+ });
919
+ const bound: string = String(query.parameters["p2"]);
920
+
921
+ expect(bound).not.toContain("GMT");
922
+ expect(bound).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/);
923
+ expect(bound).toMatch(/^\d{4}-\d{2}-\d{2}T/);
924
+ });
925
+
926
+ test("a Date and its own ISO string compile identically", () => {
927
+ /*
928
+ * The same filter reaches the server two ways: built server-side it still
929
+ * holds a Date, and round-tripped through the browser it has already been
930
+ * JSON-stringified to an ISO string. Both must produce the same predicate,
931
+ * or a saved view would mean something different from the one the user is
932
+ * looking at.
933
+ */
934
+ const fromDate: JSONColumnQuery = build({
935
+ Renewal: new GreaterThan(new Date(ISO_INSTANT)),
936
+ });
937
+ const fromString: JSONColumnQuery = build({
938
+ Renewal: new GreaterThan(ISO_INSTANT),
939
+ });
940
+
941
+ expect(fromDate.toSql(COLUMN)).toBe(fromString.toSql(COLUMN));
942
+ expect(fromDate.parameters).toEqual(fromString.parameters);
943
+ });
944
+
945
+ test("InBetween over two Dates binds both bounds as ISO strings", () => {
946
+ const query: JSONColumnQuery = build({
947
+ Window: new InBetween(
948
+ new Date("2026-01-01T00:00:00.000Z"),
949
+ new Date("2026-02-01T00:00:00.000Z"),
950
+ ),
951
+ });
952
+
953
+ expect(query.parameters["p2"]).toBe("2026-01-01T00:00:00.000Z");
954
+ expect(query.parameters["p3"]).toBe("2026-02-01T00:00:00.000Z");
955
+ expect(query.toSql(COLUMN)).not.toContain("AS NUMERIC");
956
+ });
957
+ });
958
+
959
+ describe("buildJSONColumnQuery — why comparing dates as text is sound", () => {
960
+ test("lexicographic order over ISO-8601 is chronological order", () => {
961
+ /*
962
+ * This is the justification for the whole change, so it is asserted rather
963
+ * than left in a comment. ISO-8601 is fixed-width, zero-padded and
964
+ * big-endian, which is exactly the property that makes a byte-wise string
965
+ * comparison agree with an instant comparison. Nothing else about the
966
+ * stored format may change without this test failing first.
967
+ */
968
+ const shuffled: Array<string> = [
969
+ "2026-08-17T12:00:00.000Z",
970
+ "2025-12-31T23:59:59.999Z",
971
+ "2026-08-17T00:00:00.000Z",
972
+ "2026-01-01T00:00:00.000Z",
973
+ "2026-09-01T00:00:00.000Z",
974
+ ];
975
+
976
+ const byText: Array<string> = [...shuffled].sort();
977
+ const byInstant: Array<string> = [...shuffled].sort(
978
+ (left: string, right: string): number => {
979
+ return new Date(left).getTime() - new Date(right).getTime();
980
+ },
981
+ );
982
+
983
+ expect(byText).toEqual(byInstant);
984
+ });
985
+
986
+ test("the zero padding is what makes it hold", () => {
987
+ /*
988
+ * September is "09", not "9". Were it not padded, "2026-9-01" would sort
989
+ * below "2026-10-01" as text and above it as a date, and the text
990
+ * comparison this module now relies on would be wrong for a quarter of the
991
+ * year.
992
+ */
993
+ expect("2026-09-01" < "2026-10-01").toBe(true);
994
+ expect("2026-9-01" < "2026-10-01").toBe(false);
995
+ });
996
+
997
+ test("date-only and full-timestamp values still order against each other", () => {
998
+ /*
999
+ * A field switched from Date to DateTime leaves both shapes in the column.
1000
+ * The date-only string is a prefix of the timestamp for the same day, and a
1001
+ * prefix sorts first, so "2026-08-17" reads as the start of that day —
1002
+ * which is the reading the UI already gives it.
1003
+ */
1004
+ expect("2026-08-17" < "2026-08-17T00:00:00.000Z").toBe(true);
1005
+ expect("2026-08-17" < "2026-08-18").toBe(true);
1006
+ });
1007
+ });
1008
+
1009
+ describe("buildJSONColumnQuery — other operands to the ordered operators", () => {
1010
+ test("a boolean operand compares as text rather than crashing", () => {
1011
+ /*
1012
+ * Nothing in the product builds "Regulated is greater than true", but the
1013
+ * column is untyped and a hand-built query can send anything. compareOrdered
1014
+ * must have a defined answer for every scalar, not just the two it was
1015
+ * designed for.
1016
+ */
1017
+ const query: JSONColumnQuery = build({
1018
+ Regulated: new GreaterThan(true as unknown as string),
1019
+ });
1020
+
1021
+ expect(query.toSql(COLUMN)).toContain("> CAST(:p2 AS TEXT)");
1022
+ expect(query.parameters["p2"]).toBe("true");
1023
+ });
1024
+
1025
+ test("an ordinary word compares as text", () => {
1026
+ const query: JSONColumnQuery = build({
1027
+ Severity: new GreaterThanOrEqual("medium"),
1028
+ });
1029
+
1030
+ expect(query.toSql(COLUMN)).toContain(">= CAST(:p2 AS TEXT)");
1031
+ expect(query.parameters["p2"]).toBe("medium");
1032
+ });
1033
+
1034
+ test("an empty operand compares as text and binds the empty string", () => {
1035
+ /*
1036
+ * Number("") is 0, so an empty operand used to compile to "greater than
1037
+ * zero" — a filter the user never asked for. isNumericValue rejects the
1038
+ * empty string explicitly to stop that.
1039
+ */
1040
+ const query: JSONColumnQuery = build({ Renewal: new GreaterThan("") });
1041
+
1042
+ expect(query.toSql(COLUMN)).toContain("> CAST(:p2 AS TEXT)");
1043
+ expect(query.parameters["p2"]).toBe("");
1044
+ });
1045
+
1046
+ test("a non-finite operand never reaches the numeric cast", () => {
1047
+ /*
1048
+ * Number.isFinite, not isNaN: Infinity is a number and would bind as one,
1049
+ * and `CAST(Infinity AS NUMERIC)` is an error rather than a NULL — an
1050
+ * aborted request instead of an empty table.
1051
+ */
1052
+ expect(sqlOf({ Count: new GreaterThan(Infinity) })).not.toContain(
1053
+ "AS NUMERIC",
1054
+ );
1055
+ expect(sqlOf({ Count: new GreaterThan(NaN) })).not.toContain("AS NUMERIC");
1056
+ });
1057
+
1058
+ test("GreaterThanOrNull is still numeric-only", () => {
1059
+ /*
1060
+ * Deliberately pinned at the edge of the change. The or-null variants were
1061
+ * left on compareNumeric, and nothing in the custom field facet vocabulary
1062
+ * emits them, so no date reaches them today. If a date-capable "or null"
1063
+ * filter is ever added, this test fails first and points at the gap rather
1064
+ * than letting the NaN predicate reappear somewhere new.
1065
+ */
1066
+ expect(sqlOf({ Renewal: new GreaterThanOrNull(ISO_INSTANT) })).toContain(
1067
+ "AS NUMERIC",
1068
+ );
1069
+ });
1070
+
1071
+ test("two date keys AND together and each keeps its own parameters", () => {
1072
+ const query: JSONColumnQuery = build({
1073
+ Opened: new GreaterThanOrEqual("2026-01-01T00:00:00.000Z"),
1074
+ Closed: new LessThan("2026-02-01T00:00:00.000Z"),
1075
+ });
1076
+ const sql: string = query.toSql(COLUMN);
1077
+
1078
+ expect(sql).toContain(" AND ");
1079
+ expect(query.parameters["p1"]).toBe("Opened");
1080
+ expect(query.parameters["p2"]).toBe("2026-01-01T00:00:00.000Z");
1081
+ expect(query.parameters["p3"]).toBe("Closed");
1082
+ expect(query.parameters["p4"]).toBe("2026-02-01T00:00:00.000Z");
1083
+ });
1084
+
1085
+ test("a date filter still routes the key through a parameter", () => {
1086
+ /*
1087
+ * The date branch is new SQL, and new SQL is where an interpolated key
1088
+ * creeps back in. The name of a Date custom field is user text like any
1089
+ * other.
1090
+ */
1091
+ const key: string = "Renew' OR 1=1 --";
1092
+ const query: JSONColumnQuery = build({
1093
+ [key]: new GreaterThan(ISO_INSTANT),
1094
+ });
1095
+ const sql: string = query.toSql(COLUMN);
1096
+
1097
+ expect(sql).not.toContain("OR 1=1");
1098
+ expect(sql).not.toContain(key);
1099
+ expect(query.parameters["p1"]).toBe(key);
1100
+ });
1101
+ });