@cratis/fundamentals 7.16.7 → 7.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/ConceptAs.ts +3 -0
  2. package/DateOnly.ts +112 -0
  3. package/Guid.ts +3 -0
  4. package/JsonSerializer.ts +108 -39
  5. package/TimeOnly.ts +108 -0
  6. package/TimeSpan.ts +4 -0
  7. package/ValueMap.ts +3 -0
  8. package/dist/ConceptAs.d.ts +2 -0
  9. package/dist/ConceptAs.d.ts.map +1 -1
  10. package/dist/ConceptAs.js +2 -0
  11. package/dist/ConceptAs.js.map +1 -1
  12. package/dist/DateOnly.d.ts +14 -0
  13. package/dist/DateOnly.d.ts.map +1 -0
  14. package/dist/DateOnly.js +51 -0
  15. package/dist/DateOnly.js.map +1 -0
  16. package/dist/Guid.d.ts +2 -0
  17. package/dist/Guid.d.ts.map +1 -1
  18. package/dist/Guid.js +2 -0
  19. package/dist/Guid.js.map +1 -1
  20. package/dist/JsonSerializer.d.ts +2 -0
  21. package/dist/JsonSerializer.d.ts.map +1 -1
  22. package/dist/JsonSerializer.js +45 -35
  23. package/dist/JsonSerializer.js.map +1 -1
  24. package/dist/TimeOnly.d.ts +13 -0
  25. package/dist/TimeOnly.d.ts.map +1 -0
  26. package/dist/TimeOnly.js +58 -0
  27. package/dist/TimeOnly.js.map +1 -0
  28. package/dist/TimeSpan.d.ts +2 -0
  29. package/dist/TimeSpan.d.ts.map +1 -1
  30. package/dist/TimeSpan.js +2 -0
  31. package/dist/TimeSpan.js.map +1 -1
  32. package/dist/ValueMap.d.ts +2 -0
  33. package/dist/ValueMap.d.ts.map +1 -1
  34. package/dist/ValueMap.js +2 -0
  35. package/dist/ValueMap.js.map +1 -1
  36. package/dist/cjs/ConceptAs.d.ts +2 -0
  37. package/dist/cjs/ConceptAs.d.ts.map +1 -1
  38. package/dist/cjs/ConceptAs.js +3 -0
  39. package/dist/cjs/ConceptAs.js.map +1 -1
  40. package/dist/cjs/DateOnly.d.ts +14 -0
  41. package/dist/cjs/DateOnly.d.ts.map +1 -0
  42. package/dist/cjs/DateOnly.js +56 -0
  43. package/dist/cjs/DateOnly.js.map +1 -0
  44. package/dist/cjs/Guid.d.ts +2 -0
  45. package/dist/cjs/Guid.d.ts.map +1 -1
  46. package/dist/cjs/Guid.js +3 -0
  47. package/dist/cjs/Guid.js.map +1 -1
  48. package/dist/cjs/JsonSerializer.d.ts +2 -0
  49. package/dist/cjs/JsonSerializer.d.ts.map +1 -1
  50. package/dist/cjs/JsonSerializer.js +46 -34
  51. package/dist/cjs/JsonSerializer.js.map +1 -1
  52. package/dist/cjs/TimeOnly.d.ts +13 -0
  53. package/dist/cjs/TimeOnly.d.ts.map +1 -0
  54. package/dist/cjs/TimeOnly.js +63 -0
  55. package/dist/cjs/TimeOnly.js.map +1 -0
  56. package/dist/cjs/TimeSpan.d.ts +2 -0
  57. package/dist/cjs/TimeSpan.d.ts.map +1 -1
  58. package/dist/cjs/TimeSpan.js +2 -0
  59. package/dist/cjs/TimeSpan.js.map +1 -1
  60. package/dist/cjs/ValueMap.d.ts +2 -0
  61. package/dist/cjs/ValueMap.d.ts.map +1 -1
  62. package/dist/cjs/ValueMap.js +3 -0
  63. package/dist/cjs/ValueMap.js.map +1 -1
  64. package/dist/cjs/duplicateInstanceGuard.d.ts +2 -0
  65. package/dist/cjs/duplicateInstanceGuard.d.ts.map +1 -0
  66. package/dist/cjs/duplicateInstanceGuard.js +53 -0
  67. package/dist/cjs/duplicateInstanceGuard.js.map +1 -0
  68. package/dist/cjs/geospatial/LineString.d.ts +2 -0
  69. package/dist/cjs/geospatial/LineString.d.ts.map +1 -1
  70. package/dist/cjs/geospatial/LineString.js +3 -0
  71. package/dist/cjs/geospatial/LineString.js.map +1 -1
  72. package/dist/cjs/geospatial/Point.d.ts +2 -0
  73. package/dist/cjs/geospatial/Point.d.ts.map +1 -1
  74. package/dist/cjs/geospatial/Point.js +2 -0
  75. package/dist/cjs/geospatial/Point.js.map +1 -1
  76. package/dist/cjs/geospatial/Polygon.d.ts +2 -0
  77. package/dist/cjs/geospatial/Polygon.d.ts.map +1 -1
  78. package/dist/cjs/geospatial/Polygon.js +3 -0
  79. package/dist/cjs/geospatial/Polygon.js.map +1 -1
  80. package/dist/cjs/geospatial/index.js +14 -0
  81. package/dist/cjs/geospatial/index.js.map +1 -0
  82. package/dist/cjs/index.d.ts +3 -0
  83. package/dist/cjs/index.d.ts.map +1 -1
  84. package/dist/cjs/index.js +14 -0
  85. package/dist/cjs/index.js.map +1 -1
  86. package/dist/cjs/json/DateOnlyJsonConverter.d.ts +9 -0
  87. package/dist/cjs/json/DateOnlyJsonConverter.d.ts.map +1 -0
  88. package/dist/cjs/json/DateOnlyJsonConverter.js +19 -0
  89. package/dist/cjs/json/DateOnlyJsonConverter.js.map +1 -0
  90. package/dist/cjs/json/JsonConverter.d.ts.map +1 -1
  91. package/dist/cjs/json/JsonConverter.js.map +1 -1
  92. package/dist/cjs/json/TimeOnlyJsonConverter.d.ts +9 -0
  93. package/dist/cjs/json/TimeOnlyJsonConverter.d.ts.map +1 -0
  94. package/dist/cjs/json/TimeOnlyJsonConverter.js +19 -0
  95. package/dist/cjs/json/TimeOnlyJsonConverter.js.map +1 -0
  96. package/dist/cjs/json/index.d.ts +2 -0
  97. package/dist/cjs/json/index.d.ts.map +1 -1
  98. package/dist/cjs/json/index.js +26 -0
  99. package/dist/cjs/json/index.js.map +1 -0
  100. package/dist/cjs/typeKey.d.ts +7 -0
  101. package/dist/cjs/typeKey.d.ts.map +1 -0
  102. package/dist/cjs/typeKey.js +14 -0
  103. package/dist/cjs/typeKey.js.map +1 -0
  104. package/dist/duplicateInstanceGuard.d.ts +2 -0
  105. package/dist/duplicateInstanceGuard.d.ts.map +1 -0
  106. package/dist/duplicateInstanceGuard.js +49 -0
  107. package/dist/duplicateInstanceGuard.js.map +1 -0
  108. package/dist/esm/ConceptAs.d.ts +2 -0
  109. package/dist/esm/ConceptAs.d.ts.map +1 -1
  110. package/dist/esm/ConceptAs.js +3 -0
  111. package/dist/esm/ConceptAs.js.map +1 -1
  112. package/dist/esm/DateOnly.d.ts +14 -0
  113. package/dist/esm/DateOnly.d.ts.map +1 -0
  114. package/dist/esm/DateOnly.js +54 -0
  115. package/dist/esm/DateOnly.js.map +1 -0
  116. package/dist/esm/Guid.d.ts +2 -0
  117. package/dist/esm/Guid.d.ts.map +1 -1
  118. package/dist/esm/Guid.js +3 -0
  119. package/dist/esm/Guid.js.map +1 -1
  120. package/dist/esm/JsonSerializer.d.ts +2 -0
  121. package/dist/esm/JsonSerializer.d.ts.map +1 -1
  122. package/dist/esm/JsonSerializer.js +46 -34
  123. package/dist/esm/JsonSerializer.js.map +1 -1
  124. package/dist/esm/TimeOnly.d.ts +13 -0
  125. package/dist/esm/TimeOnly.d.ts.map +1 -0
  126. package/dist/esm/TimeOnly.js +61 -0
  127. package/dist/esm/TimeOnly.js.map +1 -0
  128. package/dist/esm/TimeSpan.d.ts +2 -0
  129. package/dist/esm/TimeSpan.d.ts.map +1 -1
  130. package/dist/esm/TimeSpan.js +2 -0
  131. package/dist/esm/TimeSpan.js.map +1 -1
  132. package/dist/esm/ValueMap.d.ts +2 -0
  133. package/dist/esm/ValueMap.d.ts.map +1 -1
  134. package/dist/esm/ValueMap.js +3 -0
  135. package/dist/esm/ValueMap.js.map +1 -1
  136. package/dist/esm/duplicateInstanceGuard.d.ts +2 -0
  137. package/dist/esm/duplicateInstanceGuard.d.ts.map +1 -0
  138. package/dist/esm/duplicateInstanceGuard.js +51 -0
  139. package/dist/esm/duplicateInstanceGuard.js.map +1 -0
  140. package/dist/esm/geospatial/LineString.d.ts +2 -0
  141. package/dist/esm/geospatial/LineString.d.ts.map +1 -1
  142. package/dist/esm/geospatial/LineString.js +3 -0
  143. package/dist/esm/geospatial/LineString.js.map +1 -1
  144. package/dist/esm/geospatial/Point.d.ts +2 -0
  145. package/dist/esm/geospatial/Point.d.ts.map +1 -1
  146. package/dist/esm/geospatial/Point.js +2 -0
  147. package/dist/esm/geospatial/Point.js.map +1 -1
  148. package/dist/esm/geospatial/Polygon.d.ts +2 -0
  149. package/dist/esm/geospatial/Polygon.d.ts.map +1 -1
  150. package/dist/esm/geospatial/Polygon.js +3 -0
  151. package/dist/esm/geospatial/Polygon.js.map +1 -1
  152. package/dist/esm/geospatial/index.js +5 -0
  153. package/dist/esm/geospatial/index.js.map +1 -0
  154. package/dist/esm/index.d.ts +3 -0
  155. package/dist/esm/index.d.ts.map +1 -1
  156. package/dist/esm/index.js +5 -0
  157. package/dist/esm/index.js.map +1 -1
  158. package/dist/esm/json/DateOnlyJsonConverter.d.ts +9 -0
  159. package/dist/esm/json/DateOnlyJsonConverter.d.ts.map +1 -0
  160. package/dist/esm/json/DateOnlyJsonConverter.js +17 -0
  161. package/dist/esm/json/DateOnlyJsonConverter.js.map +1 -0
  162. package/dist/esm/json/JsonConverter.d.ts.map +1 -1
  163. package/dist/esm/json/JsonConverter.js.map +1 -1
  164. package/dist/esm/json/TimeOnlyJsonConverter.d.ts +9 -0
  165. package/dist/esm/json/TimeOnlyJsonConverter.d.ts.map +1 -0
  166. package/dist/esm/json/TimeOnlyJsonConverter.js +17 -0
  167. package/dist/esm/json/TimeOnlyJsonConverter.js.map +1 -0
  168. package/dist/esm/json/index.d.ts +2 -0
  169. package/dist/esm/json/index.d.ts.map +1 -1
  170. package/dist/esm/json/index.js +11 -0
  171. package/dist/esm/json/index.js.map +1 -0
  172. package/dist/esm/typeKey.d.ts +7 -0
  173. package/dist/esm/typeKey.d.ts.map +1 -0
  174. package/dist/esm/typeKey.js +8 -0
  175. package/dist/esm/typeKey.js.map +1 -0
  176. package/dist/geospatial/LineString.d.ts +2 -0
  177. package/dist/geospatial/LineString.d.ts.map +1 -1
  178. package/dist/geospatial/LineString.js +2 -0
  179. package/dist/geospatial/LineString.js.map +1 -1
  180. package/dist/geospatial/Point.d.ts +2 -0
  181. package/dist/geospatial/Point.d.ts.map +1 -1
  182. package/dist/geospatial/Point.js +2 -0
  183. package/dist/geospatial/Point.js.map +1 -1
  184. package/dist/geospatial/Polygon.d.ts +2 -0
  185. package/dist/geospatial/Polygon.d.ts.map +1 -1
  186. package/dist/geospatial/Polygon.js +2 -0
  187. package/dist/geospatial/Polygon.js.map +1 -1
  188. package/dist/index.d.ts +3 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +3 -0
  191. package/dist/index.js.map +1 -1
  192. package/dist/json/DateOnlyJsonConverter.d.ts +9 -0
  193. package/dist/json/DateOnlyJsonConverter.d.ts.map +1 -0
  194. package/dist/json/DateOnlyJsonConverter.js +14 -0
  195. package/dist/json/DateOnlyJsonConverter.js.map +1 -0
  196. package/dist/json/JsonConverter.d.ts.map +1 -1
  197. package/dist/json/JsonConverter.js.map +1 -1
  198. package/dist/json/TimeOnlyJsonConverter.d.ts +9 -0
  199. package/dist/json/TimeOnlyJsonConverter.d.ts.map +1 -0
  200. package/dist/json/TimeOnlyJsonConverter.js +14 -0
  201. package/dist/json/TimeOnlyJsonConverter.js.map +1 -0
  202. package/dist/json/index.d.ts +2 -0
  203. package/dist/json/index.d.ts.map +1 -1
  204. package/dist/json/index.js +2 -0
  205. package/dist/json/index.js.map +1 -1
  206. package/dist/tsconfig.tsbuildinfo +1 -1
  207. package/dist/typeKey.d.ts +7 -0
  208. package/dist/typeKey.d.ts.map +1 -0
  209. package/dist/typeKey.js +6 -0
  210. package/dist/typeKey.js.map +1 -0
  211. package/duplicateInstanceGuard.ts +114 -0
  212. package/for_DateOnly/when_parsing_a_calendar_date.ts +13 -0
  213. package/for_DateOnly/when_reading_it_west_of_utc.ts +24 -0
  214. package/for_JsonSerializer/RegisteredConverterTypes.ts +45 -0
  215. package/for_JsonSerializer/when_a_converter_is_registered_for_a_concept.ts +46 -0
  216. package/for_JsonSerializer/when_a_converter_is_registered_for_a_type_that_already_has_one.ts +25 -0
  217. package/for_JsonSerializer/when_a_converter_is_registered_for_a_type_the_package_already_converts.ts +51 -0
  218. package/for_JsonSerializer/when_a_converter_is_registered_for_a_value_map.ts +49 -0
  219. package/for_JsonSerializer/when_a_converter_is_registered_for_an_unknown_type.ts +30 -0
  220. package/for_JsonSerializer/when_a_type_comes_from_another_copy_of_the_package.ts +63 -0
  221. package/for_JsonSerializer/when_deserializing_object_with_absent_collection.ts +36 -0
  222. package/for_JsonSerializer/when_deserializing_temporal_values.ts +31 -0
  223. package/for_JsonSerializer/when_metadata_is_written_by_another_copy_of_the_package.ts +46 -0
  224. package/for_JsonSerializer/when_no_converter_is_registered_for_a_type.ts +32 -0
  225. package/for_JsonSerializer/when_round_tripping_a_value_map_of_primitive_values.ts +35 -0
  226. package/for_JsonSerializer/when_round_tripping_concepts_outside_a_declared_field.ts +39 -0
  227. package/for_TimeOnly/when_parsing_a_time_of_day.ts +17 -0
  228. package/for_TimeOnly/when_parsing_the_shapes_the_server_sends.ts +24 -0
  229. package/for_duplicateInstanceGuard/given/a_realm.ts +62 -0
  230. package/for_duplicateInstanceGuard/when_a_second_copy_is_loaded.ts +28 -0
  231. package/for_duplicateInstanceGuard/when_a_third_copy_is_loaded.ts +20 -0
  232. package/for_duplicateInstanceGuard/when_checking_the_package_manifest.ts +26 -0
  233. package/for_duplicateInstanceGuard/when_one_copy_is_loaded.ts +17 -0
  234. package/for_duplicateInstanceGuard/when_one_copy_registers_more_than_once.ts +20 -0
  235. package/for_duplicateInstanceGuard/when_something_else_holds_the_global_key.ts +25 -0
  236. package/for_duplicateInstanceGuard/when_the_runtime_has_no_console.ts +30 -0
  237. package/geospatial/LineString.ts +3 -0
  238. package/geospatial/Point.ts +3 -0
  239. package/geospatial/Polygon.ts +3 -0
  240. package/index.ts +3 -0
  241. package/json/DateOnlyJsonConverter.ts +28 -0
  242. package/json/JsonConverter.ts +5 -0
  243. package/json/TimeOnlyJsonConverter.ts +28 -0
  244. package/json/index.ts +2 -0
  245. package/package.json +1 -1
  246. package/typeKey.ts +54 -0
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DateOnly } from '../DateOnly';
5
+
6
+ /**
7
+ * The reason this type exists. A calendar date forced into a `Date` becomes UTC midnight, and every browser-local
8
+ * getter west of UTC then reads it back as the previous day - correct in Europe, wrong in the Americas, which is
9
+ * how the mistake survives development and reaches users.
10
+ */
11
+ describe('when reading it west of utc', () => {
12
+ const dueDate = DateOnly.parse('2026-05-12');
13
+
14
+ const asAnInstant = new Date('2026-05-12');
15
+ const instantWestOfUtc = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(asAnInstant);
16
+
17
+ it('should be the day the server sent, whatever zone reads it', () => dueDate.toString().should.equal('2026-05-12'));
18
+
19
+ // Pins the behavior being avoided, so the reason for the type cannot quietly stop being true.
20
+ it('should not be what an instant would have rendered as', () => instantWestOfUtc.should.equal('2026-05-11'));
21
+
22
+ it('should keep the day when a local Date is asked for', () => dueDate.toDate().getDate().should.equal(12));
23
+ it('should keep the month when a local Date is asked for', () => (dueDate.toDate().getMonth() + 1).should.equal(5));
24
+ });
@@ -0,0 +1,45 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Constructor } from '../Constructor';
5
+ import { JsonConverter } from '../json';
6
+
7
+ /* eslint-disable @typescript-eslint/no-explicit-any */
8
+
9
+ /**
10
+ * A domain type the package knows nothing about, standing in for the type a consumer needs to put on
11
+ * the wire in a shape only they can decide.
12
+ */
13
+ export class Temperature {
14
+ constructor(readonly celsius: number) {}
15
+ }
16
+
17
+ /** Puts a {@link Temperature} on the wire as the string a consumer chose. */
18
+ export class TemperatureJsonConverter extends JsonConverter<Temperature> {
19
+ get type(): Constructor<Temperature> {
20
+ return Temperature;
21
+ }
22
+
23
+ read(value: any): Temperature {
24
+ return new Temperature(Number(String(value).replace('C', '')));
25
+ }
26
+
27
+ write(value: Temperature): any {
28
+ return `${value.celsius}C`;
29
+ }
30
+ }
31
+
32
+ /** A second, disagreeing converter for the same type, for pinning what a re-registration does. */
33
+ export class TemperatureInFahrenheitJsonConverter extends JsonConverter<Temperature> {
34
+ get type(): Constructor<Temperature> {
35
+ return Temperature;
36
+ }
37
+
38
+ read(value: any): Temperature {
39
+ return new Temperature((Number(String(value).replace('F', '')) - 32) / 1.8);
40
+ }
41
+
42
+ write(value: Temperature): any {
43
+ return `${(value.celsius * 1.8) + 32}F`;
44
+ }
45
+ }
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { ConceptAs } from '../ConceptAs';
5
+ import { Constructor } from '../Constructor';
6
+ import { JsonSerializer } from '../JsonSerializer';
7
+ import { JsonConverter } from '../json';
8
+ import { field } from '../fieldDecorator';
9
+
10
+ /* eslint-disable @typescript-eslint/no-explicit-any */
11
+
12
+ class Reference extends ConceptAs<string> {}
13
+
14
+ /** Registered for the concept itself, which is the thing that does not reach it. */
15
+ class ReferenceJsonConverter extends JsonConverter<Reference> {
16
+ get type(): Constructor<Reference> {
17
+ return Reference;
18
+ }
19
+
20
+ read(): Reference {
21
+ return new Reference('read-by-converter');
22
+ }
23
+
24
+ write(): any {
25
+ return 'written-by-converter';
26
+ }
27
+ }
28
+
29
+ class Document {
30
+ @field(Reference)
31
+ reference!: Reference;
32
+ }
33
+
34
+ describe('when a converter is registered for a concept', () => {
35
+ JsonSerializer.registerConverter(new ReferenceJsonConverter());
36
+
37
+ const document = new Document();
38
+ document.reference = new Reference('the-underlying-value');
39
+
40
+ const serialized = JSON.parse(JsonSerializer.serialize(document));
41
+ const deserialized = JsonSerializer.deserialize(Document, '{"reference":"the-underlying-value"}');
42
+
43
+ it('should unwrap the concept rather than reach the converter on the way out', () => serialized.reference.should.equal('the-underlying-value'));
44
+ it('should rebuild the concept rather than reach the converter on the way in', () => deserialized.reference.value.should.equal('the-underlying-value'));
45
+ it('should still produce the concept type', () => deserialized.reference.should.be.instanceof(Reference));
46
+ });
@@ -0,0 +1,25 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { JsonSerializer } from '../JsonSerializer';
5
+ import { field } from '../fieldDecorator';
6
+ import { Temperature, TemperatureJsonConverter, TemperatureInFahrenheitJsonConverter } from './RegisteredConverterTypes';
7
+
8
+ class Reading {
9
+ @field(Temperature)
10
+ temperature!: Temperature;
11
+ }
12
+
13
+ describe('when a converter is registered for a type that already has one', () => {
14
+ JsonSerializer.registerConverter(new TemperatureJsonConverter());
15
+ JsonSerializer.registerConverter(new TemperatureInFahrenheitJsonConverter());
16
+
17
+ const reading = new Reading();
18
+ reading.temperature = new Temperature(100);
19
+
20
+ const serialized = JSON.parse(JsonSerializer.serialize(reading));
21
+ const deserialized = JsonSerializer.deserialize(Reading, '{"temperature":"32F"}');
22
+
23
+ it('should serialize through the converter registered last', () => serialized.temperature.should.equal('212F'));
24
+ it('should deserialize through the converter registered last', () => deserialized.temperature.celsius.should.equal(0));
25
+ });
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Constructor } from '../Constructor';
5
+ import { JsonSerializer } from '../JsonSerializer';
6
+ import { DateJsonConverter, JsonConverter } from '../json';
7
+ import { field } from '../fieldDecorator';
8
+
9
+ /* eslint-disable @typescript-eslint/no-explicit-any */
10
+
11
+ /**
12
+ * Writes a Date as a calendar day rather than an instant - the shape a consumer reaches for when a
13
+ * value that is a date on the wire keeps arriving as UTC midnight and rendering a day early.
14
+ */
15
+ class CalendarDateJsonConverter extends JsonConverter<Date> {
16
+ get type(): Constructor<Date> {
17
+ return Date;
18
+ }
19
+
20
+ read(value: any): Date {
21
+ return new Date(`${value}T00:00:00`);
22
+ }
23
+
24
+ write(value: Date): any {
25
+ return value.toISOString().substring(0, 10);
26
+ }
27
+ }
28
+
29
+ class Booking {
30
+ @field(Date)
31
+ arrivesOn!: Date;
32
+ }
33
+
34
+ describe('when a converter is registered for a type the package already converts', () => {
35
+ const booking = new Booking();
36
+ booking.arrivesOn = new Date(Date.UTC(2026, 7, 4, 9, 30));
37
+
38
+ const beforeRegistering = JSON.parse(JsonSerializer.serialize(booking));
39
+
40
+ JsonSerializer.registerConverter(new CalendarDateJsonConverter());
41
+ const afterRegistering = JSON.parse(JsonSerializer.serialize(booking));
42
+
43
+ // Put the built-in back before any assertion runs, so a failing expectation cannot leave the
44
+ // registry - which is module scope shared by every spec file - holding this spec's converter.
45
+ JsonSerializer.registerConverter(new DateJsonConverter());
46
+ const afterRestoring = JSON.parse(JsonSerializer.serialize(booking));
47
+
48
+ it('should use the built-in converter until one is registered', () => beforeRegistering.arrivesOn.should.equal('2026-08-04T09:30:00.000Z'));
49
+ it('should take the place of the built-in converter', () => afterRegistering.arrivesOn.should.equal('2026-08-04'));
50
+ it('should hand back to a built-in converter registered after it', () => afterRestoring.arrivesOn.should.equal('2026-08-04T09:30:00.000Z'));
51
+ });
@@ -0,0 +1,49 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Constructor } from '../Constructor';
5
+ import { JsonSerializer } from '../JsonSerializer';
6
+ import { ValueMap } from '../ValueMap';
7
+ import { JsonConverter, ValueMapJsonConverter } from '../json';
8
+ import { field } from '../fieldDecorator';
9
+
10
+ /* eslint-disable @typescript-eslint/no-explicit-any */
11
+
12
+ class CountingValueMapJsonConverter extends JsonConverter<ValueMap<any, any>> {
13
+ get type(): Constructor<ValueMap<any, any>> {
14
+ return ValueMap;
15
+ }
16
+
17
+ read(): ValueMap<any, any> {
18
+ return new ValueMap<any, any>().set('read-by', 'converter');
19
+ }
20
+
21
+ write(): any {
22
+ return 'written-by-converter';
23
+ }
24
+ }
25
+
26
+ class Holder {
27
+ @field(ValueMap, { genericArguments: [String, String] })
28
+ entries!: ValueMap<string, string>;
29
+ }
30
+
31
+ describe('when a converter is registered for a value map', () => {
32
+ const holder = new Holder();
33
+ holder.entries = new ValueMap<string, string>().set('a', 'b');
34
+
35
+ JsonSerializer.registerConverter(new CountingValueMapJsonConverter());
36
+ const serialized = JSON.parse(JsonSerializer.serialize(holder));
37
+ const deserialized = JsonSerializer.deserialize(Holder, '{"entries":{"a":"b"}}');
38
+
39
+ // Put the built-in back before any assertion runs. Writing a ValueMap does go through the
40
+ // registered converter, so leaving this one in place would change every later spec that
41
+ // serializes one.
42
+ JsonSerializer.registerConverter(new ValueMapJsonConverter());
43
+
44
+ const deserializedKeys = [...deserialized.entries.entries()].map(entry => entry[0]);
45
+
46
+ it('should reach the converter on the way out', () => serialized.entries.should.equal('written-by-converter'));
47
+ it('should not reach the converter on the way in', () => deserializedKeys.should.not.contain('read-by'));
48
+ it('should read the field back from the payload instead', () => deserializedKeys.should.deep.equal(['a']));
49
+ });
@@ -0,0 +1,30 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { JsonSerializer } from '../JsonSerializer';
5
+ import { field } from '../fieldDecorator';
6
+ import { Temperature, TemperatureJsonConverter } from './RegisteredConverterTypes';
7
+
8
+ class Reading {
9
+ @field(Temperature)
10
+ temperature!: Temperature;
11
+
12
+ @field(String)
13
+ station!: string;
14
+ }
15
+
16
+ describe('when a converter is registered for an unknown type', () => {
17
+ JsonSerializer.registerConverter(new TemperatureJsonConverter());
18
+
19
+ const reading = new Reading();
20
+ reading.temperature = new Temperature(21.5);
21
+ reading.station = 'Blindern';
22
+
23
+ const serialized = JSON.parse(JsonSerializer.serialize(reading));
24
+ const deserialized = JsonSerializer.deserialize(Reading, '{"temperature":"18C","station":"Blindern"}');
25
+
26
+ it('should serialize through the registered converter', () => serialized.temperature.should.equal('21.5C'));
27
+ it('should leave the other properties alone', () => serialized.station.should.equal('Blindern'));
28
+ it('should deserialize through the registered converter', () => deserialized.temperature.should.be.instanceof(Temperature));
29
+ it('should deserialize to the converted value', () => deserialized.temperature.celsius.should.equal(18));
30
+ });
@@ -0,0 +1,63 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { Constructor } from '../Constructor';
5
+ import { Fields } from '../Fields';
6
+ import { JsonSerializer } from '../JsonSerializer';
7
+
8
+ /**
9
+ * Loads a module with its own scope, standing in for the second copy of the package a nested install
10
+ * or a dual ESM/CommonJS load produces. A distinct identifier is evaluated rather than served from
11
+ * cache, and an evaluation is what makes the class objects distinct - which is the whole problem.
12
+ *
13
+ * `@vite-ignore` keeps the identifier away from Vite's dynamic-import-vars pass, which would try to
14
+ * resolve the variation by globbing the file system: the variation is in the query, not the path.
15
+ */
16
+ const anotherCopyOf = async <TModule>(module: string): Promise<TModule> =>
17
+ await import(/* @vite-ignore */ `../${module}?anotherCopy`) as TModule;
18
+
19
+ describe('when a type comes from another copy of the package', () => {
20
+ let serializedGuid: { id: string };
21
+ let serializedConcept: { reference: string };
22
+ let deserializedConcept: { value: string };
23
+ let deserializedValueMapKeys: unknown[];
24
+ let guidClassIsDistinct: boolean;
25
+ let conceptBaseIsDistinct: boolean;
26
+
27
+ beforeEach(async () => {
28
+ const thisCopyGuid = await import('../Guid');
29
+ const otherGuid = await anotherCopyOf<typeof thisCopyGuid>('Guid');
30
+ const thisCopyConceptAs = await import('../ConceptAs');
31
+ const otherConceptAs = await anotherCopyOf<typeof thisCopyConceptAs>('ConceptAs');
32
+ const otherValueMap = await anotherCopyOf<typeof import('../ValueMap')>('ValueMap');
33
+
34
+ guidClassIsDistinct = thisCopyGuid.Guid !== otherGuid.Guid;
35
+ conceptBaseIsDistinct = thisCopyConceptAs.ConceptAs !== otherConceptAs.ConceptAs;
36
+
37
+ class Reference extends otherConceptAs.ConceptAs<string> {}
38
+
39
+ class Holder {
40
+ reference!: unknown;
41
+ entries!: unknown;
42
+ }
43
+
44
+ Fields.addFieldToType(Holder, 'reference', Reference as unknown as Constructor, false, [], []);
45
+ Fields.addFieldToType(Holder, 'entries', otherValueMap.ValueMap, false, [], [String, String]);
46
+
47
+ serializedGuid = JSON.parse(JsonSerializer.serialize({ id: otherGuid.Guid.parse('a1b2c3d4-1111-2222-3333-444455556666') }));
48
+ serializedConcept = JSON.parse(JsonSerializer.serialize({ reference: new Reference('the-value') }));
49
+
50
+ const holder = JsonSerializer.deserialize(Holder, '{"reference":"the-value","entries":{"a":"b"}}');
51
+ deserializedConcept = holder.reference as { value: string };
52
+ const entries = holder.entries as InstanceType<typeof otherValueMap.ValueMap>;
53
+ deserializedValueMapKeys = [...entries.entries()].map(entry => entry[0]);
54
+ });
55
+
56
+ it('should be a genuinely different Guid class', () => guidClassIsDistinct.should.be.true);
57
+ it('should be a genuinely different ConceptAs base', () => conceptBaseIsDistinct.should.be.true);
58
+
59
+ it('should write the other copy\'s Guid as a string', () => serializedGuid.id.should.equal('a1b2c3d4-1111-2222-3333-444455556666'));
60
+ it('should unwrap a concept deriving from the other copy\'s ConceptAs', () => serializedConcept.reference.should.equal('the-value'));
61
+ it('should rebuild a concept deriving from the other copy\'s ConceptAs', () => deserializedConcept.value.should.equal('the-value'));
62
+ it('should read the other copy\'s ValueMap from its generic arguments', () => deserializedValueMapKeys.should.deep.equal(['a']));
63
+ });
@@ -0,0 +1,36 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { field } from '../fieldDecorator';
5
+ import { JsonSerializer } from '../JsonSerializer';
6
+
7
+
8
+ class TheType {
9
+ @field(String)
10
+ someString!: string;
11
+
12
+ @field(String, true)
13
+ someCollection!: string[];
14
+
15
+ @field(String, true)
16
+ someNullCollection!: string[];
17
+ }
18
+
19
+ const json = '{' +
20
+ ' "someString": "forty two",' +
21
+ ' "someNullCollection": null' +
22
+ '}';
23
+
24
+ describe('when deserializing object with absent collection', () => {
25
+ const result = JsonSerializer.deserialize(TheType, json);
26
+
27
+ it('should hold the value that was there', () => result.someString.should.equal('forty two'));
28
+
29
+ // The declared type says this is an array. A producer that leaves an empty collection out of the payload
30
+ // would otherwise hand back undefined behind that type, so every reader has to guard it.
31
+ it('should give the absent collection an empty array', () => result.someCollection.should.be.an('array'));
32
+ it('should give the absent collection no items', () => result.someCollection.length.should.equal(0));
33
+
34
+ // An explicit null is the producer saying "no collection", which is not the same as an empty one.
35
+ it('should leave an explicit null alone', () => expect(result.someNullCollection).to.be.null);
36
+ });
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DateOnly } from '../DateOnly';
5
+ import { field } from '../fieldDecorator';
6
+ import { JsonSerializer } from '../JsonSerializer';
7
+ import { TimeOnly } from '../TimeOnly';
8
+
9
+ class Appointment {
10
+ @field(DateOnly)
11
+ on!: DateOnly;
12
+
13
+ @field(TimeOnly)
14
+ at!: TimeOnly;
15
+ }
16
+
17
+ /**
18
+ * The end of the chain the types exist for: what the server sends has to arrive as the value it names, through the
19
+ * registered converters, without anyone at the call site parsing anything.
20
+ */
21
+ describe('when deserializing temporal values', () => {
22
+ const deserialized = JsonSerializer.deserializeFromInstance(Appointment, { on: '2026-05-12', at: '14:30:45' });
23
+
24
+ it('should deserialize the date as a DateOnly', () => (deserialized.on instanceof DateOnly).should.be.true);
25
+ it('should deserialize the time as a TimeOnly', () => (deserialized.at instanceof TimeOnly).should.be.true);
26
+ it('should keep the calendar date', () => deserialized.on.toString().should.equal('2026-05-12'));
27
+ it('should keep the time of day', () => deserialized.at.toString().should.equal('14:30:45'));
28
+
29
+ it('should serialize back to what the server sent', () =>
30
+ JSON.parse(JsonSerializer.serialize(deserialized)).should.deep.equal({ on: '2026-05-12', at: '14:30:45' }));
31
+ });
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DerivedType } from '../DerivedType';
5
+ import { Fields } from '../Fields';
6
+
7
+ /**
8
+ * Pins why `Fields` and `DerivedType` were left keyed on the constructor while the converter registry
9
+ * was not.
10
+ *
11
+ * Neither holds state in module scope. Both write reflect metadata keyed on the target constructor, so
12
+ * the state lives with the type being described rather than with the class describing it, and a
13
+ * duplicated `Fields` reads what another `Fields` wrote. (In a real duplicate install the two copies
14
+ * also share one metadata store, because the polyfill installs only when the global `Reflect` lacks it
15
+ * - here a single `reflection` module is loaded, which is the same store by construction.)
16
+ *
17
+ * That is the whole reason those two kept comparing constructors while the converter registry stopped.
18
+ * If it ever stops being true, this goes red and they need the same treatment the converters got.
19
+ */
20
+ describe('when metadata is written by another copy of the package', () => {
21
+ let fieldsAreDistinct: boolean;
22
+ let derivedTypeIsDistinct: boolean;
23
+ let fieldNamesReadByTheOtherCopy: string[];
24
+ let derivedTypeReadByTheOtherCopy: string | undefined;
25
+
26
+ beforeEach(async () => {
27
+ const otherFields = await import(/* @vite-ignore */ '../Fields?anotherCopy') as typeof import('../Fields');
28
+ const otherDerivedType = await import(/* @vite-ignore */ '../DerivedType?anotherCopy') as typeof import('../DerivedType');
29
+
30
+ fieldsAreDistinct = Fields !== otherFields.Fields;
31
+ derivedTypeIsDistinct = DerivedType !== otherDerivedType.DerivedType;
32
+
33
+ class Subject { }
34
+
35
+ Fields.addFieldToType(Subject, 'name', String, false, [], []);
36
+ DerivedType.set(Subject, 'an-identifier');
37
+
38
+ fieldNamesReadByTheOtherCopy = otherFields.Fields.getFieldsForType(Subject).map(_ => _.name);
39
+ derivedTypeReadByTheOtherCopy = otherDerivedType.DerivedType.get(Subject);
40
+ });
41
+
42
+ it('should be a genuinely different Fields', () => fieldsAreDistinct.should.be.true);
43
+ it('should be a genuinely different DerivedType', () => derivedTypeIsDistinct.should.be.true);
44
+ it('should read a field the other copy wrote', () => fieldNamesReadByTheOtherCopy.should.deep.equal(['name']));
45
+ it('should read a derived type the other copy wrote', () => derivedTypeReadByTheOtherCopy!.should.equal('an-identifier'));
46
+ });
@@ -0,0 +1,32 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { JsonSerializer } from '../JsonSerializer';
5
+ import { field } from '../fieldDecorator';
6
+
7
+ class Coordinates {
8
+ @field(Number)
9
+ latitude!: number;
10
+
11
+ @field(Number)
12
+ longitude!: number;
13
+ }
14
+
15
+ class Site {
16
+ @field(Coordinates)
17
+ coordinates!: Coordinates;
18
+ }
19
+
20
+ describe('when no converter is registered for a type', () => {
21
+ const site = new Site();
22
+ site.coordinates = new Coordinates();
23
+ site.coordinates.latitude = 59.94;
24
+ site.coordinates.longitude = 10.72;
25
+
26
+ const serialized = JSON.parse(JsonSerializer.serialize(site));
27
+ const deserialized = JsonSerializer.deserialize(Site, '{"coordinates":{"latitude":59.94,"longitude":10.72}}');
28
+
29
+ it('should serialize it property by property as before', () => serialized.coordinates.should.deep.equal({ latitude: 59.94, longitude: 10.72 }));
30
+ it('should deserialize it to the declared type', () => deserialized.coordinates.should.be.instanceof(Coordinates));
31
+ it('should deserialize its properties', () => deserialized.coordinates.latitude.should.equal(59.94));
32
+ });
@@ -0,0 +1,35 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { JsonSerializer } from '../JsonSerializer';
5
+ import { ValueMap } from '../ValueMap';
6
+ import { field } from '../fieldDecorator';
7
+
8
+ class Settings {
9
+ @field(ValueMap, { genericArguments: [String, String] })
10
+ text!: ValueMap<string, string>;
11
+
12
+ @field(ValueMap, { genericArguments: [String, Number] })
13
+ numbers!: ValueMap<string, number>;
14
+
15
+ @field(ValueMap, { genericArguments: [String, Boolean] })
16
+ flags!: ValueMap<string, boolean>;
17
+ }
18
+
19
+ describe('when round tripping a value map of primitive values', () => {
20
+ const settings = new Settings();
21
+ settings.text = new ValueMap<string, string>().set('greeting', 'hello');
22
+ settings.numbers = new ValueMap<string, number>().set('retries', 3);
23
+ settings.flags = new ValueMap<string, boolean>().set('enabled', true);
24
+
25
+ const written = JsonSerializer.serialize(settings);
26
+ const read = JsonSerializer.deserialize(Settings, written);
27
+
28
+ it('should write the values as the primitives they are', () => written.should.equal('{"text":{"greeting":"hello"},"numbers":{"retries":3},"flags":{"enabled":true}}'));
29
+ it('should read a string value back as a string', () => read.text.get('greeting')!.should.equal('hello'));
30
+ it('should read a number value back as a number', () => read.numbers.get('retries')!.should.equal(3));
31
+ it('should read a boolean value back as a boolean', () => read.flags.get('enabled')!.should.equal(true));
32
+ it('should read a string value back as a primitive rather than a String object', () => (typeof read.text.get('greeting')).should.equal('string'));
33
+ it('should read a number value back as a primitive rather than a Number object', () => (typeof read.numbers.get('retries')).should.equal('number'));
34
+ it('should read a boolean value back as a primitive rather than a Boolean object', () => (typeof read.flags.get('enabled')).should.equal('boolean'));
35
+ });
@@ -0,0 +1,39 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { ConceptAs } from '../ConceptAs';
5
+ import { JsonSerializer } from '../JsonSerializer';
6
+ import { ValueMap } from '../ValueMap';
7
+ import { field } from '../fieldDecorator';
8
+
9
+ class Tag extends ConceptAs<string> {}
10
+
11
+ class Article {
12
+ @field(Tag)
13
+ primaryTag!: Tag;
14
+
15
+ @field(Tag, true)
16
+ tags!: Tag[];
17
+
18
+ @field(ValueMap, { genericArguments: [String, Tag] })
19
+ tagBySection!: ValueMap<string, Tag>;
20
+ }
21
+
22
+ describe('when round tripping concepts outside a declared field', () => {
23
+ const article = new Article();
24
+ article.primaryTag = new Tag('news');
25
+ article.tags = [new Tag('sport'), new Tag('weather')];
26
+ article.tagBySection = new ValueMap<string, Tag>().set('front', new Tag('lead'));
27
+
28
+ const written = JsonSerializer.serialize(article);
29
+ const read = JsonSerializer.deserialize(Article, written);
30
+
31
+ it('should write a concept in a collection as its underlying value', () => written.should.contain('"tags":["sport","weather"]'));
32
+ it('should write a concept in a value map as its underlying value', () => written.should.contain('"tagBySection":{"front":"lead"}'));
33
+ it('should write a concept in a declared field as its underlying value', () => written.should.contain('"primaryTag":"news"'));
34
+
35
+ it('should read a collection back as concepts', () => read.tags.map(_ => _.value).should.deep.equal(['sport', 'weather']));
36
+ it('should read a collection element back as the concept type', () => read.tags[0].should.be.instanceof(Tag));
37
+ it('should read a value map value back as a concept', () => read.tagBySection.get('front')!.value.should.equal('lead'));
38
+ it('should read a value map value back as the concept type', () => read.tagBySection.get('front')!.should.be.instanceof(Tag));
39
+ });
@@ -0,0 +1,17 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { TimeOnly } from '../TimeOnly';
5
+
6
+ describe('when parsing a time of day', () => {
7
+ const parsed = TimeOnly.parse('14:30:45');
8
+
9
+ it('should have the hour', () => parsed.hour.should.equal(14));
10
+ it('should have the minute', () => parsed.minute.should.equal(30));
11
+ it('should have the second', () => parsed.second.should.equal(45));
12
+ it('should render back to what it was parsed from', () => parsed.toString().should.equal('14:30:45'));
13
+
14
+ // A Date needs a date, and a time of day has none - so this is destroyed outright rather than shifted, which
15
+ // is the starker of the two temporal cases and the reason the type is not a Date.
16
+ it('should not be something a Date could have held', () => isNaN(new Date('14:30:45').getTime()).should.be.true);
17
+ });
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { TimeOnly } from '../TimeOnly';
5
+
6
+ /**
7
+ * The server omits the seconds and the fraction when they are zero, and writes up to seven fractional digits where
8
+ * JavaScript holds three. Every one of those shapes has to parse, and a value has to survive a parse followed by a
9
+ * render without moving.
10
+ */
11
+ describe('when parsing the shapes the server sends', () => {
12
+ const withoutSeconds = TimeOnly.parse('14:30');
13
+ const withFraction = TimeOnly.parse('14:30:45.1234567');
14
+ const midnight = TimeOnly.parse('00:00:00');
15
+
16
+ it('should default the seconds when they are omitted', () => withoutSeconds.second.should.equal(0));
17
+ it('should render omitted seconds explicitly', () => withoutSeconds.toString().should.equal('14:30:00'));
18
+
19
+ // Truncated rather than rounded, so a parse followed by a render never moves the value forward.
20
+ it('should truncate the fraction to milliseconds', () => withFraction.millisecond.should.equal(123));
21
+ it('should render the fraction it kept', () => withFraction.toString().should.equal('14:30:45.123'));
22
+
23
+ it('should parse midnight', () => midnight.toString().should.equal('00:00:00'));
24
+ });