@next-box/appsettings 0.0.0-stage → 0.0.2

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 (260) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -2
  3. package/bin/appsettings.mjs +7 -0
  4. package/dist/cjs/cli/cli/command.cjs +2 -0
  5. package/dist/cjs/cli/cli/command.cjs.map +1 -0
  6. package/dist/cjs/cli/cli/handler.cjs +2 -0
  7. package/dist/cjs/cli/cli/handler.cjs.map +1 -0
  8. package/dist/cjs/cli/cli/index.cjs +2 -0
  9. package/dist/cjs/cli/cli/index.cjs.map +1 -0
  10. package/dist/cjs/components/AppSettingsProvider.cjs +3 -0
  11. package/dist/cjs/components/AppSettingsProvider.cjs.map +1 -0
  12. package/dist/cjs/components/Setting.cjs +3 -0
  13. package/dist/cjs/components/Setting.cjs.map +1 -0
  14. package/dist/cjs/config/AppSettingsImplementation.cjs +2 -0
  15. package/dist/cjs/config/AppSettingsImplementation.cjs.map +1 -0
  16. package/dist/cjs/config/ConfigReader.cjs +2 -0
  17. package/dist/cjs/config/ConfigReader.cjs.map +1 -0
  18. package/dist/cjs/config/ConfigStore.cjs +2 -0
  19. package/dist/cjs/config/ConfigStore.cjs.map +1 -0
  20. package/dist/cjs/config/ScopedSettingsImplementation.cjs +2 -0
  21. package/dist/cjs/config/ScopedSettingsImplementation.cjs.map +1 -0
  22. package/dist/cjs/config/helpers/freezeDeep.cjs +2 -0
  23. package/dist/cjs/config/helpers/freezeDeep.cjs.map +1 -0
  24. package/dist/cjs/hooks/useAppSetting.cjs +2 -0
  25. package/dist/cjs/hooks/useAppSetting.cjs.map +1 -0
  26. package/dist/cjs/hooks/useAppSettings.cjs +2 -0
  27. package/dist/cjs/hooks/useAppSettings.cjs.map +1 -0
  28. package/dist/cjs/hooks/useAppSettingsContext.cjs +2 -0
  29. package/dist/cjs/hooks/useAppSettingsContext.cjs.map +1 -0
  30. package/dist/cjs/index.cjs +2 -0
  31. package/dist/cjs/index.cjs.map +1 -0
  32. package/dist/cjs/server/config/ConfigMerger.cjs +2 -0
  33. package/dist/cjs/server/config/ConfigMerger.cjs.map +1 -0
  34. package/dist/cjs/server/config/ConfigValidator.cjs +2 -0
  35. package/dist/cjs/server/config/ConfigValidator.cjs.map +1 -0
  36. package/dist/cjs/server/config/createAppSettings.cjs +2 -0
  37. package/dist/cjs/server/config/createAppSettings.cjs.map +1 -0
  38. package/dist/cjs/server/server.cjs +2 -0
  39. package/dist/cjs/server/server.cjs.map +1 -0
  40. package/dist/cjs/server/sources/AppSettingsSource.cjs +2 -0
  41. package/dist/cjs/server/sources/AppSettingsSource.cjs.map +1 -0
  42. package/dist/cjs/server/sources/EnvironmentSource.cjs +2 -0
  43. package/dist/cjs/server/sources/EnvironmentSource.cjs.map +1 -0
  44. package/dist/cjs/server/sources/MemorySource.cjs +2 -0
  45. package/dist/cjs/server/sources/MemorySource.cjs.map +1 -0
  46. package/dist/cjs/server/sources/RemoteSource.cjs +2 -0
  47. package/dist/cjs/server/sources/RemoteSource.cjs.map +1 -0
  48. package/dist/cjs/server/sources/helpers/loadJson.cjs +2 -0
  49. package/dist/cjs/server/sources/helpers/loadJson.cjs.map +1 -0
  50. package/dist/esm/cli/cli/command.mjs +2 -0
  51. package/dist/esm/cli/cli/command.mjs.map +1 -0
  52. package/dist/esm/cli/cli/handler.mjs +2 -0
  53. package/dist/esm/cli/cli/handler.mjs.map +1 -0
  54. package/dist/esm/cli/cli/index.mjs +2 -0
  55. package/dist/esm/cli/cli/index.mjs.map +1 -0
  56. package/dist/esm/components/AppSettingsProvider.mjs +3 -0
  57. package/dist/esm/components/AppSettingsProvider.mjs.map +1 -0
  58. package/dist/esm/components/Setting.mjs +3 -0
  59. package/dist/esm/components/Setting.mjs.map +1 -0
  60. package/dist/esm/config/AppSettingsImplementation.mjs +2 -0
  61. package/dist/esm/config/AppSettingsImplementation.mjs.map +1 -0
  62. package/dist/esm/config/ConfigReader.mjs +2 -0
  63. package/dist/esm/config/ConfigReader.mjs.map +1 -0
  64. package/dist/esm/config/ConfigStore.mjs +2 -0
  65. package/dist/esm/config/ConfigStore.mjs.map +1 -0
  66. package/dist/esm/config/ScopedSettingsImplementation.mjs +2 -0
  67. package/dist/esm/config/ScopedSettingsImplementation.mjs.map +1 -0
  68. package/dist/esm/config/helpers/freezeDeep.mjs +2 -0
  69. package/dist/esm/config/helpers/freezeDeep.mjs.map +1 -0
  70. package/dist/esm/hooks/useAppSetting.mjs +2 -0
  71. package/dist/esm/hooks/useAppSetting.mjs.map +1 -0
  72. package/dist/esm/hooks/useAppSettings.mjs +2 -0
  73. package/dist/esm/hooks/useAppSettings.mjs.map +1 -0
  74. package/dist/esm/hooks/useAppSettingsContext.mjs +2 -0
  75. package/dist/esm/hooks/useAppSettingsContext.mjs.map +1 -0
  76. package/dist/esm/index.mjs +2 -0
  77. package/dist/esm/index.mjs.map +1 -0
  78. package/dist/esm/server/config/ConfigMerger.mjs +2 -0
  79. package/dist/esm/server/config/ConfigMerger.mjs.map +1 -0
  80. package/dist/esm/server/config/ConfigValidator.mjs +2 -0
  81. package/dist/esm/server/config/ConfigValidator.mjs.map +1 -0
  82. package/dist/esm/server/config/createAppSettings.mjs +2 -0
  83. package/dist/esm/server/config/createAppSettings.mjs.map +1 -0
  84. package/dist/esm/server/server.mjs +2 -0
  85. package/dist/esm/server/server.mjs.map +1 -0
  86. package/dist/esm/server/sources/AppSettingsSource.mjs +2 -0
  87. package/dist/esm/server/sources/AppSettingsSource.mjs.map +1 -0
  88. package/dist/esm/server/sources/EnvironmentSource.mjs +2 -0
  89. package/dist/esm/server/sources/EnvironmentSource.mjs.map +1 -0
  90. package/dist/esm/server/sources/MemorySource.mjs +2 -0
  91. package/dist/esm/server/sources/MemorySource.mjs.map +1 -0
  92. package/dist/esm/server/sources/RemoteSource.mjs +2 -0
  93. package/dist/esm/server/sources/RemoteSource.mjs.map +1 -0
  94. package/dist/esm/server/sources/helpers/loadJson.mjs +2 -0
  95. package/dist/esm/server/sources/helpers/loadJson.mjs.map +1 -0
  96. package/dist/production.analysis.txt +89 -0
  97. package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts +14 -0
  98. package/dist/types/cjs/__testUtils__/components/ErrorBoundary.d.cts.map +1 -0
  99. package/dist/types/cjs/cli/command.d.cts +7 -0
  100. package/dist/types/cjs/cli/command.d.cts.map +1 -0
  101. package/dist/types/cjs/cli/handler.d.cts +7 -0
  102. package/dist/types/cjs/cli/handler.d.cts.map +1 -0
  103. package/dist/types/cjs/cli/index.d.cts +2 -0
  104. package/dist/types/cjs/cli/index.d.cts.map +1 -0
  105. package/dist/types/cjs/components/AppSettingsProvider.d.cts +7 -0
  106. package/dist/types/cjs/components/AppSettingsProvider.d.cts.map +1 -0
  107. package/dist/types/cjs/components/Setting.d.cts +20 -0
  108. package/dist/types/cjs/components/Setting.d.cts.map +1 -0
  109. package/dist/types/cjs/config/AppSettingsImplementation.d.cts +15 -0
  110. package/dist/types/cjs/config/AppSettingsImplementation.d.cts.map +1 -0
  111. package/dist/types/cjs/config/ConfigMerger.d.cts +4 -0
  112. package/dist/types/cjs/config/ConfigMerger.d.cts.map +1 -0
  113. package/dist/types/cjs/config/ConfigReader.d.cts +14 -0
  114. package/dist/types/cjs/config/ConfigReader.d.cts.map +1 -0
  115. package/dist/types/cjs/config/ConfigStore.d.cts +6 -0
  116. package/dist/types/cjs/config/ConfigStore.d.cts.map +1 -0
  117. package/dist/types/cjs/config/ConfigValidator.d.cts +7 -0
  118. package/dist/types/cjs/config/ConfigValidator.d.cts.map +1 -0
  119. package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts +13 -0
  120. package/dist/types/cjs/config/ScopedSettingsImplementation.d.cts.map +1 -0
  121. package/dist/types/cjs/config/createAppSettings.d.cts +3 -0
  122. package/dist/types/cjs/config/createAppSettings.d.cts.map +1 -0
  123. package/dist/types/cjs/config/helpers/freezeDeep.d.cts +2 -0
  124. package/dist/types/cjs/config/helpers/freezeDeep.d.cts.map +1 -0
  125. package/dist/types/cjs/hooks/useAppSetting.d.cts +6 -0
  126. package/dist/types/cjs/hooks/useAppSetting.d.cts.map +1 -0
  127. package/dist/types/cjs/hooks/useAppSettings.d.cts +6 -0
  128. package/dist/types/cjs/hooks/useAppSettings.d.cts.map +1 -0
  129. package/dist/types/cjs/hooks/useAppSettingsContext.d.cts +5 -0
  130. package/dist/types/cjs/hooks/useAppSettingsContext.d.cts.map +1 -0
  131. package/dist/types/cjs/index.d.cts +10 -0
  132. package/dist/types/cjs/index.d.cts.map +1 -0
  133. package/dist/types/cjs/server.d.cts +8 -0
  134. package/dist/types/cjs/server.d.cts.map +1 -0
  135. package/dist/types/cjs/sources/AppSettingsSource.d.cts +14 -0
  136. package/dist/types/cjs/sources/AppSettingsSource.d.cts.map +1 -0
  137. package/dist/types/cjs/sources/EnvironmentSource.d.cts +16 -0
  138. package/dist/types/cjs/sources/EnvironmentSource.d.cts.map +1 -0
  139. package/dist/types/cjs/sources/MemorySource.d.cts +12 -0
  140. package/dist/types/cjs/sources/MemorySource.d.cts.map +1 -0
  141. package/dist/types/cjs/sources/RemoteSource.d.cts +16 -0
  142. package/dist/types/cjs/sources/RemoteSource.d.cts.map +1 -0
  143. package/dist/types/cjs/sources/helpers/loadJson.d.cts +3 -0
  144. package/dist/types/cjs/sources/helpers/loadJson.d.cts.map +1 -0
  145. package/dist/types/cjs/types/AppSettings.types.d.cts +26 -0
  146. package/dist/types/cjs/types/AppSettings.types.d.cts.map +1 -0
  147. package/dist/types/cjs/types/Config.types.d.cts +12 -0
  148. package/dist/types/cjs/types/Config.types.d.cts.map +1 -0
  149. package/dist/types/cjs/types/Source.types.d.cts +5 -0
  150. package/dist/types/cjs/types/Source.types.d.cts.map +1 -0
  151. package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts +14 -0
  152. package/dist/types/esm/__testUtils__/components/ErrorBoundary.d.ts.map +1 -0
  153. package/dist/types/esm/cli/command.d.ts +7 -0
  154. package/dist/types/esm/cli/command.d.ts.map +1 -0
  155. package/dist/types/esm/cli/handler.d.ts +7 -0
  156. package/dist/types/esm/cli/handler.d.ts.map +1 -0
  157. package/dist/types/esm/cli/index.d.ts +2 -0
  158. package/dist/types/esm/cli/index.d.ts.map +1 -0
  159. package/dist/types/esm/components/AppSettingsProvider.d.ts +7 -0
  160. package/dist/types/esm/components/AppSettingsProvider.d.ts.map +1 -0
  161. package/dist/types/esm/components/Setting.d.ts +20 -0
  162. package/dist/types/esm/components/Setting.d.ts.map +1 -0
  163. package/dist/types/esm/config/AppSettingsImplementation.d.ts +15 -0
  164. package/dist/types/esm/config/AppSettingsImplementation.d.ts.map +1 -0
  165. package/dist/types/esm/config/ConfigMerger.d.ts +4 -0
  166. package/dist/types/esm/config/ConfigMerger.d.ts.map +1 -0
  167. package/dist/types/esm/config/ConfigReader.d.ts +14 -0
  168. package/dist/types/esm/config/ConfigReader.d.ts.map +1 -0
  169. package/dist/types/esm/config/ConfigStore.d.ts +6 -0
  170. package/dist/types/esm/config/ConfigStore.d.ts.map +1 -0
  171. package/dist/types/esm/config/ConfigValidator.d.ts +7 -0
  172. package/dist/types/esm/config/ConfigValidator.d.ts.map +1 -0
  173. package/dist/types/esm/config/ScopedSettingsImplementation.d.ts +13 -0
  174. package/dist/types/esm/config/ScopedSettingsImplementation.d.ts.map +1 -0
  175. package/dist/types/esm/config/createAppSettings.d.ts +3 -0
  176. package/dist/types/esm/config/createAppSettings.d.ts.map +1 -0
  177. package/dist/types/esm/config/helpers/freezeDeep.d.ts +2 -0
  178. package/dist/types/esm/config/helpers/freezeDeep.d.ts.map +1 -0
  179. package/dist/types/esm/hooks/useAppSetting.d.ts +6 -0
  180. package/dist/types/esm/hooks/useAppSetting.d.ts.map +1 -0
  181. package/dist/types/esm/hooks/useAppSettings.d.ts +6 -0
  182. package/dist/types/esm/hooks/useAppSettings.d.ts.map +1 -0
  183. package/dist/types/esm/hooks/useAppSettingsContext.d.ts +5 -0
  184. package/dist/types/esm/hooks/useAppSettingsContext.d.ts.map +1 -0
  185. package/dist/types/esm/index.d.ts +10 -0
  186. package/dist/types/esm/index.d.ts.map +1 -0
  187. package/dist/types/esm/server.d.ts +8 -0
  188. package/dist/types/esm/server.d.ts.map +1 -0
  189. package/dist/types/esm/sources/AppSettingsSource.d.ts +14 -0
  190. package/dist/types/esm/sources/AppSettingsSource.d.ts.map +1 -0
  191. package/dist/types/esm/sources/EnvironmentSource.d.ts +16 -0
  192. package/dist/types/esm/sources/EnvironmentSource.d.ts.map +1 -0
  193. package/dist/types/esm/sources/MemorySource.d.ts +12 -0
  194. package/dist/types/esm/sources/MemorySource.d.ts.map +1 -0
  195. package/dist/types/esm/sources/RemoteSource.d.ts +16 -0
  196. package/dist/types/esm/sources/RemoteSource.d.ts.map +1 -0
  197. package/dist/types/esm/sources/helpers/loadJson.d.ts +3 -0
  198. package/dist/types/esm/sources/helpers/loadJson.d.ts.map +1 -0
  199. package/dist/types/esm/types/AppSettings.types.d.ts +26 -0
  200. package/dist/types/esm/types/AppSettings.types.d.ts.map +1 -0
  201. package/dist/types/esm/types/Config.types.d.ts +12 -0
  202. package/dist/types/esm/types/Config.types.d.ts.map +1 -0
  203. package/dist/types/esm/types/Source.types.d.ts +5 -0
  204. package/dist/types/esm/types/Source.types.d.ts.map +1 -0
  205. package/dist/types/tsconfig.build.tsbuildinfo +1 -0
  206. package/package.json +85 -3
  207. package/rollup.config.mjs +25 -0
  208. package/src/__testUtils__/AppSettingsSchema.d.ts +9 -0
  209. package/src/__testUtils__/components/ErrorBoundary.tsx +25 -0
  210. package/src/cli/command.test.ts +36 -0
  211. package/src/cli/command.ts +33 -0
  212. package/src/cli/handler.test.ts +61 -0
  213. package/src/cli/handler.ts +58 -0
  214. package/src/cli/index.test.ts +45 -0
  215. package/src/cli/index.ts +13 -0
  216. package/src/components/AppSettingsProvider.test.tsx +25 -0
  217. package/src/components/AppSettingsProvider.tsx +18 -0
  218. package/src/components/Setting.test.tsx +59 -0
  219. package/src/components/Setting.tsx +43 -0
  220. package/src/config/AppSettingsImplementation.test.ts +65 -0
  221. package/src/config/AppSettingsImplementation.ts +61 -0
  222. package/src/config/ConfigMerger.test.ts +44 -0
  223. package/src/config/ConfigMerger.ts +9 -0
  224. package/src/config/ConfigReader.test.ts +56 -0
  225. package/src/config/ConfigReader.ts +45 -0
  226. package/src/config/ConfigStore.test.ts +32 -0
  227. package/src/config/ConfigStore.ts +12 -0
  228. package/src/config/ConfigValidator.test.ts +30 -0
  229. package/src/config/ConfigValidator.ts +13 -0
  230. package/src/config/ScopedSettingsImplementation.test.ts +31 -0
  231. package/src/config/ScopedSettingsImplementation.ts +36 -0
  232. package/src/config/createAppSettings.test.ts +84 -0
  233. package/src/config/createAppSettings.ts +18 -0
  234. package/src/config/helpers/freezeDeep.test.ts +41 -0
  235. package/src/config/helpers/freezeDeep.ts +15 -0
  236. package/src/hooks/useAppSetting.test.tsx +37 -0
  237. package/src/hooks/useAppSetting.ts +14 -0
  238. package/src/hooks/useAppSettings.test.tsx +37 -0
  239. package/src/hooks/useAppSettings.ts +17 -0
  240. package/src/hooks/useAppSettingsContext.test.tsx +45 -0
  241. package/src/hooks/useAppSettingsContext.ts +17 -0
  242. package/src/index.ts +11 -0
  243. package/src/server.ts +7 -0
  244. package/src/sources/AppSettingsSource.test.ts +61 -0
  245. package/src/sources/AppSettingsSource.ts +30 -0
  246. package/src/sources/EnvironmentSource.test.ts +45 -0
  247. package/src/sources/EnvironmentSource.ts +87 -0
  248. package/src/sources/MemorySource.test.ts +31 -0
  249. package/src/sources/MemorySource.ts +21 -0
  250. package/src/sources/RemoteSource.test.ts +80 -0
  251. package/src/sources/RemoteSource.ts +50 -0
  252. package/src/sources/helpers/loadJson.test.ts +74 -0
  253. package/src/sources/helpers/loadJson.ts +29 -0
  254. package/src/types/AppSettings.types.test.ts +61 -0
  255. package/src/types/AppSettings.types.ts +30 -0
  256. package/src/types/Config.types.test.ts +36 -0
  257. package/src/types/Config.types.ts +27 -0
  258. package/src/types/Source.types.ts +4 -0
  259. package/tsconfig.build.json +10 -0
  260. package/tsconfig.json +10 -0
@@ -0,0 +1,61 @@
1
+ import { isPlainObject } from 'remeda';
2
+ import { type Get } from 'type-fest';
3
+ import { ScopedSettingsImplementation } from '#config/ScopedSettingsImplementation.ts';
4
+ import { type InternalAppSettings, type ScopedSettings } from '#types/AppSettings.types.ts';
5
+ import { type SettingPath, type SettingSectionPath } from '#types/Config.types.ts';
6
+ import { ConfigReader } from './ConfigReader.ts';
7
+ import { ConfigStore } from './ConfigStore.ts';
8
+
9
+ export class AppSettingsImplementation<T extends object> implements InternalAppSettings<T> {
10
+ private readonly store: ConfigStore<T>;
11
+
12
+ constructor(settings: T) {
13
+ this.store = new ConfigStore(settings);
14
+ }
15
+
16
+ get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
17
+ get(path: string): unknown {
18
+ return this.read(path);
19
+ }
20
+
21
+ read(path: string): unknown {
22
+ return new ConfigReader(this.store.getSnapshot()).read(path);
23
+ }
24
+
25
+ getAll(): Readonly<T> {
26
+ return this.store.getSnapshot();
27
+ }
28
+
29
+ getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
30
+ getOrThrow(path: string): unknown {
31
+ const value = new ConfigReader(this.store.getSnapshot()).read(path);
32
+
33
+ if (value === undefined) {
34
+ throw new Error(`Required app setting "${path}" is missing.`);
35
+ }
36
+
37
+ return value;
38
+ }
39
+
40
+ getSection<P extends SettingSectionPath<T>>(path: P): Get<T, P> | undefined;
41
+ getSection(path: string): unknown {
42
+ return new ConfigReader(this.store.getSnapshot()).read(path);
43
+ }
44
+
45
+ has(path: SettingPath<T> | SettingSectionPath<T>): boolean;
46
+ has(path: string): boolean {
47
+ return new ConfigReader(this.store.getSnapshot()).has(path);
48
+ }
49
+
50
+ scope<P extends SettingSectionPath<T>>(path: P): ScopedSettings<Extract<Get<T, P>, object>>;
51
+ scope(path: string): ScopedSettings<object> {
52
+ const reader = new ConfigReader(this.store.getSnapshot());
53
+ const section = reader.read(path);
54
+
55
+ if (!isPlainObject(section)) {
56
+ throw new Error(`App setting section "${path}" is missing.`);
57
+ }
58
+
59
+ return new ScopedSettingsImplementation(section, path);
60
+ }
61
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { ConfigMerger } from './ConfigMerger.ts';
3
+
4
+ describe('ConfigMerger', () => {
5
+ it('When configuration layers contain nested objects, then later values override recursively', () => {
6
+ // Arrange
7
+ const defaults = { Api: { BaseUrl: 'https://default.example.com', Timeout: 1000 }, Enabled: true };
8
+ const overrides = { Api: { Timeout: 5000 } };
9
+ const merger = new ConfigMerger();
10
+
11
+ // Act
12
+ const result = merger.merge(defaults, overrides);
13
+
14
+ // Assert
15
+ expect(result).toEqual({ Api: { BaseUrl: 'https://default.example.com', Timeout: 5000 }, Enabled: true });
16
+ });
17
+
18
+ it('When later layers contain arrays, then they replace earlier arrays without mutating inputs', () => {
19
+ // Arrange
20
+ const defaults = { Api: { Timeout: 1000 }, Values: [1, 2] };
21
+ const overrides = { Values: [3] };
22
+ const merger = new ConfigMerger();
23
+
24
+ // Act
25
+ const result = merger.merge(defaults, overrides);
26
+
27
+ // Assert
28
+ expect(result).toEqual({ Api: { Timeout: 1000 }, Values: [3] });
29
+ expect(defaults).toEqual({ Api: { Timeout: 1000 }, Values: [1, 2] });
30
+ expect(overrides).toEqual({ Values: [3] });
31
+ });
32
+
33
+ it('When there are no override layers, then it returns the first layer', () => {
34
+ // Arrange
35
+ const defaults = { Enabled: true };
36
+ const merger = new ConfigMerger();
37
+
38
+ // Act
39
+ const result = merger.merge(defaults);
40
+
41
+ // Assert
42
+ expect(result).toBe(defaults);
43
+ });
44
+ });
@@ -0,0 +1,9 @@
1
+ import { mergeDeep } from 'remeda';
2
+
3
+ export class ConfigMerger {
4
+ merge<T extends object>(first: T, ...configs: readonly T[]): T {
5
+ // TypeScript cannot guarantee that merging arbitrary objects produces T
6
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
7
+ return configs.reduce((merged, config) => mergeDeep(merged, config) as T, first);
8
+ }
9
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { ConfigReader } from './ConfigReader.ts';
3
+
4
+ describe('ConfigReader', () => {
5
+ it('When a dotted path points to a leaf, then it returns that value', () => {
6
+ // Arrange
7
+ const reader = new ConfigReader({ Api: { Timeout: 5000 } });
8
+
9
+ // Act
10
+ const result = reader.read('Api.Timeout');
11
+
12
+ // Assert
13
+ expect(result).toBe(5000);
14
+ });
15
+
16
+ it('When a path is missing or traverses a primitive, then it returns undefined and reports no match', () => {
17
+ // Arrange
18
+ const reader = new ConfigReader({ Api: { Timeout: 5000 }, Name: 'App' });
19
+
20
+ // Act
21
+ const missing = reader.read('Api.Retry.Count');
22
+ const throughPrimitive = reader.read('Name.Length');
23
+
24
+ // Assert
25
+ expect(missing).toBeUndefined();
26
+ expect(throughPrimitive).toBeUndefined();
27
+ expect(reader.has('Api.Missing')).toBe(false);
28
+ });
29
+
30
+ it('When a path points to a defined value or section, then it reads and reports it', () => {
31
+ // Arrange
32
+ const reader = new ConfigReader({ Api: { Timeout: 5000 }, Empty: undefined });
33
+
34
+ // Act
35
+ const section = reader.getSection('Api');
36
+ const leaf = reader.get('Api.Timeout');
37
+
38
+ // Assert
39
+ expect(section).toEqual({ Timeout: 5000 });
40
+ expect(leaf).toBe(5000);
41
+ expect(reader.has('Api')).toBe(true);
42
+ expect(reader.has('Empty')).toBe(false);
43
+ });
44
+
45
+ it('When a section is scoped, then the returned reader reads paths relative to that section', () => {
46
+ // Arrange
47
+ const reader = new ConfigReader({ Api: { Timeout: 5000 } });
48
+ const scopedReader = reader.scope('Api');
49
+
50
+ // Act
51
+ const result = scopedReader.read('Timeout');
52
+
53
+ // Assert
54
+ expect(result).toBe(5000);
55
+ });
56
+ });
@@ -0,0 +1,45 @@
1
+ import { isPlainObject } from 'remeda';
2
+ import { type Get } from 'type-fest';
3
+ import { type Path, type Scope, type ConfigReader as ZcbConfigReader, createConfigReader } from 'zcb';
4
+
5
+ export class ConfigReader<T extends object> {
6
+ private reader: ZcbConfigReader<T>;
7
+ private readonly config: T;
8
+
9
+ constructor(config: T) {
10
+ this.config = config;
11
+ this.reader = createConfigReader(config);
12
+ }
13
+
14
+ private getAt(path: string): unknown {
15
+ return path.split('.').reduce<unknown>((current, key) => {
16
+ if (!isPlainObject(current)) {
17
+ return;
18
+ }
19
+
20
+ return current[key];
21
+ }, this.config);
22
+ }
23
+
24
+ public get<P extends Path<T>>(path: P): Get<T, P> {
25
+ return this.reader.read(path);
26
+ }
27
+
28
+ public getSection<P extends Scope<T>>(path: P): Get<T, P> {
29
+ // Required as underlying getAt needs to be kept permissive
30
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
31
+ return this.getAt(path) as Get<T, P>;
32
+ }
33
+
34
+ public has(path: string): boolean {
35
+ return this.getAt(path) !== undefined;
36
+ }
37
+
38
+ public read(path: string): unknown {
39
+ return this.getAt(path);
40
+ }
41
+
42
+ public scope<P extends Scope<T>>(path: P): ZcbConfigReader<Get<T, P>> {
43
+ return this.reader.scope(path);
44
+ }
45
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { ConfigStore } from './ConfigStore.ts';
3
+
4
+ describe('ConfigStore', () => {
5
+ it('When initialized, then it keeps a stable frozen snapshot detached from the input', () => {
6
+ // Arrange
7
+ const initial = { Api: { Timeout: 5000 } };
8
+ const store = new ConfigStore(initial);
9
+
10
+ // Act
11
+ const snapshot = store.getSnapshot();
12
+ initial.Api.Timeout = 100;
13
+
14
+ // Assert
15
+ expect(snapshot).toEqual({ Api: { Timeout: 5000 } });
16
+ expect(store.getSnapshot()).toBe(snapshot);
17
+ expect(Object.isFrozen(snapshot)).toBe(true);
18
+ expect(Object.isFrozen(snapshot.Api)).toBe(true);
19
+ });
20
+
21
+ it('When initialized with arrays, then the snapshot array and its entries are frozen', () => {
22
+ // Arrange
23
+ const store = new ConfigStore({ Servers: [{ Host: 'api.example.com' }] });
24
+
25
+ // Act
26
+ const snapshot = store.getSnapshot();
27
+
28
+ // Assert
29
+ expect(Object.isFrozen(snapshot.Servers)).toBe(true);
30
+ expect(Object.isFrozen(snapshot.Servers[0])).toBe(true);
31
+ });
32
+ });
@@ -0,0 +1,12 @@
1
+ import { clone } from 'remeda';
2
+ import { freezeDeep } from '#config/helpers/freezeDeep.ts';
3
+
4
+ export class ConfigStore<T extends object> {
5
+ private snapshot: Readonly<T>;
6
+
7
+ public getSnapshot = (): Readonly<T> => this.snapshot;
8
+
9
+ constructor(initial: T) {
10
+ this.snapshot = freezeDeep(clone(initial));
11
+ }
12
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { z } from 'zod';
3
+ import { ConfigValidator } from './ConfigValidator.ts';
4
+
5
+ describe('ConfigValidator', () => {
6
+ it('When a value matches the schema, then it returns the parsed value', () => {
7
+ // Arrange
8
+ const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
9
+ const validator = new ConfigValidator(schema);
10
+ const value = { Api: { Timeout: 5000 } };
11
+
12
+ // Act
13
+ const result = validator.validate(value);
14
+
15
+ // Assert
16
+ expect(result).toEqual(value);
17
+ });
18
+
19
+ it('When a value does not match the schema, then it throws the schema validation error', () => {
20
+ // Arrange
21
+ const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
22
+ const validator = new ConfigValidator(schema);
23
+
24
+ // Act
25
+ const validateInvalidValue = () => validator.validate({ Api: { Timeout: 'slow' } });
26
+
27
+ // Assert
28
+ expect(validateInvalidValue).toThrow();
29
+ });
30
+ });
@@ -0,0 +1,13 @@
1
+ import { type ZodType, type output } from 'zod';
2
+
3
+ export class ConfigValidator<T extends ZodType> {
4
+ private readonly schema: T;
5
+
6
+ constructor(schema: T) {
7
+ this.schema = schema;
8
+ }
9
+
10
+ validate(value: unknown): output<T> {
11
+ return this.schema.parse(value);
12
+ }
13
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { ScopedSettingsImplementation } from './ScopedSettingsImplementation.ts';
3
+
4
+ describe('ScopedSettingsImplementation', () => {
5
+ it('When a path exists within the scope, then it returns the value and scoped data', () => {
6
+ // Arrange
7
+ const section = { Api: { Timeout: 5000 }, Name: 'App', Retry: undefined };
8
+ const settings = new ScopedSettingsImplementation(section, 'Platform.Api');
9
+
10
+ // Act
11
+ const value = settings.get('Api.Timeout');
12
+ const all = settings.getAll();
13
+
14
+ // Assert
15
+ expect(value).toBe(5000);
16
+ expect(all).toBe(section);
17
+ });
18
+
19
+ it('When a required scoped value is missing, then it throws with the full path', () => {
20
+ // Arrange
21
+ const settings = new ScopedSettingsImplementation({ Retry: undefined }, 'Platform.Api');
22
+
23
+ // Act
24
+ const getMissingValue = () => {
25
+ settings.getOrThrow('Retry');
26
+ };
27
+
28
+ // Assert
29
+ expect(getMissingValue).toThrow('Required app setting "Platform.Api.Retry" is missing.');
30
+ });
31
+ });
@@ -0,0 +1,36 @@
1
+ import { type Get } from 'type-fest';
2
+ import { ConfigReader } from '#config/ConfigReader.ts';
3
+ import { type ScopedSettings } from '#types/AppSettings.types.ts';
4
+ import { type SettingPath } from '#types/Config.types.ts';
5
+
6
+ export class ScopedSettingsImplementation<T extends object> implements ScopedSettings<T> {
7
+ private readonly reader: ConfigReader<T>;
8
+ private readonly section: T;
9
+ private readonly parentPath: string;
10
+
11
+ constructor(section: T, parentPath: string) {
12
+ this.section = section;
13
+ this.parentPath = parentPath;
14
+ this.reader = new ConfigReader(section);
15
+ }
16
+
17
+ get<P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
18
+ get(path: string): unknown {
19
+ return this.reader.read(path);
20
+ }
21
+
22
+ getAll(): Readonly<T> {
23
+ return this.section;
24
+ }
25
+
26
+ getOrThrow<P extends SettingPath<T>>(path: P): Get<T, P>;
27
+ getOrThrow(path: string): unknown {
28
+ const value = this.reader.read(path);
29
+
30
+ if (value === undefined) {
31
+ throw new Error(`Required app setting "${this.parentPath}.${path}" is missing.`);
32
+ }
33
+
34
+ return value;
35
+ }
36
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { z } from 'zod';
3
+ import { type SettingsSource } from '#types/Source.types.ts';
4
+ import { createAppSettings } from './createAppSettings.ts';
5
+
6
+ describe('createAppSettings', () => {
7
+ it('When sources are provided, then it loads them in order, merges them, and validates the result', async () => {
8
+ // Arrange
9
+ const schema = z.object({ Api: z.object({ BaseUrl: z.string(), Timeout: z.number() }) });
10
+ const defaults = { Api: { BaseUrl: 'https://default.example.com', Timeout: 1000 } };
11
+ const firstLoad = jest.fn(() => Promise.resolve({ Api: { Timeout: 3000 } }));
12
+ const secondLoad = jest.fn(() => Promise.resolve({ Api: { BaseUrl: 'https://api.example.com' } }));
13
+ const firstSource: SettingsSource = { load: firstLoad, name: 'first' };
14
+ const secondSource: SettingsSource = { load: secondLoad, name: 'second' };
15
+
16
+ // Act
17
+ const result = await createAppSettings({ defaults, schema, sources: [firstSource, secondSource] });
18
+
19
+ // Assert
20
+ expect(firstLoad).toHaveBeenCalledTimes(1);
21
+ expect(secondLoad).toHaveBeenCalledTimes(1);
22
+ expect(result).toEqual({ Api: { BaseUrl: 'https://api.example.com', Timeout: 3000 } });
23
+ });
24
+
25
+ it('When no sources are provided, then it validates the defaults', async () => {
26
+ // Arrange
27
+ const defaults = { Api: { Timeout: 1000 } };
28
+ const schema = z.object({ Api: z.object({ Timeout: z.number() }) });
29
+
30
+ // Act
31
+ const result = await createAppSettings({ defaults, schema });
32
+
33
+ // Assert
34
+ expect(result).toEqual(defaults);
35
+ });
36
+
37
+ it('When a source rejects, then it rejects without continuing to later sources', async () => {
38
+ // Arrange
39
+ const error = new Error('Source failed');
40
+
41
+ const firstLoad = jest.fn(() => {
42
+ throw error;
43
+ });
44
+
45
+ // @ts-expect-error Fine for test
46
+ const secondLoad: jest.Mock<() => never> = jest.fn(() => ({}));
47
+
48
+ const sources: SettingsSource[] = [
49
+ { load: firstLoad, name: 'first' },
50
+ { load: secondLoad, name: 'second' },
51
+ ];
52
+
53
+ const options = { defaults: {}, schema: z.object({}), sources };
54
+
55
+ // Act
56
+ const result = createAppSettings(options);
57
+
58
+ // Assert
59
+ await expect(result).rejects.toBe(error);
60
+ expect(secondLoad).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('When the merged settings fail validation, then it rejects with a schema error', async () => {
64
+ // Arrange
65
+ const defaults = { Api: { Timeout: 1000 } };
66
+
67
+ const invalidSource: SettingsSource = {
68
+ load: () => Promise.resolve({ Api: { Timeout: 'slow' } }),
69
+ name: 'invalid',
70
+ };
71
+
72
+ const options = {
73
+ defaults,
74
+ schema: z.object({ Api: z.object({ Timeout: z.number() }) }),
75
+ sources: [invalidSource],
76
+ };
77
+
78
+ // Act
79
+ const result = createAppSettings(options);
80
+
81
+ // Assert
82
+ await expect(result).rejects.toThrow();
83
+ });
84
+ });
@@ -0,0 +1,18 @@
1
+ import { type CreateAppSettingsOptions } from '#types/AppSettings.types.ts';
2
+ import { ConfigMerger } from './ConfigMerger.ts';
3
+ import { ConfigValidator } from './ConfigValidator.ts';
4
+
5
+ export const createAppSettings = async <T extends object>({
6
+ defaults,
7
+ schema,
8
+ sources = [],
9
+ }: CreateAppSettingsOptions<T>): Promise<Readonly<T>> => {
10
+ const loadedSources: Record<string, unknown>[] = [];
11
+
12
+ for (const source of sources) {
13
+ loadedSources.push(await source.load());
14
+ }
15
+
16
+ const merged = new ConfigMerger().merge(defaults, ...loadedSources);
17
+ return new ConfigValidator(schema).validate(merged);
18
+ };
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { freezeDeep } from './freezeDeep.ts';
3
+
4
+ describe('freezeDeep', () => {
5
+ it('When given nested plain objects, then it freezes each object and returns the original value', () => {
6
+ // Arrange
7
+ const value = { Api: { Retry: { Count: 3 } } };
8
+
9
+ // Act
10
+ const result = freezeDeep(value);
11
+
12
+ // Assert
13
+ expect(result).toBe(value);
14
+ expect(Object.isFrozen(value)).toBe(true);
15
+ expect(Object.isFrozen(value.Api)).toBe(true);
16
+ expect(Object.isFrozen(value.Api.Retry)).toBe(true);
17
+ });
18
+
19
+ it('When given arrays containing objects, then it freezes the array and its nested objects', () => {
20
+ // Arrange
21
+ const value = { Servers: [{ Host: 'api.example.com' }] };
22
+
23
+ // Act
24
+ freezeDeep(value);
25
+
26
+ // Assert
27
+ expect(Object.isFrozen(value.Servers)).toBe(true);
28
+ expect(Object.isFrozen(value.Servers[0])).toBe(true);
29
+ });
30
+
31
+ it('When given a primitive, then it returns the value unchanged', () => {
32
+ // Arrange
33
+ const value = 'setting';
34
+
35
+ // Act
36
+ const result = freezeDeep(value);
37
+
38
+ // Assert
39
+ expect(result).toBe(value);
40
+ });
41
+ });
@@ -0,0 +1,15 @@
1
+ import { isPlainObject } from 'remeda';
2
+
3
+ export const freezeDeep = <T>(value: T): T => {
4
+ if ((isPlainObject(value) || Array.isArray(value)) && !Object.isFrozen(value)) {
5
+ const values: unknown[] = Object.values(value);
6
+
7
+ for (const entry of values) {
8
+ freezeDeep(entry);
9
+ }
10
+
11
+ Object.freeze(value);
12
+ }
13
+
14
+ return value;
15
+ };
@@ -0,0 +1,37 @@
1
+ /** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
2
+
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { renderHook } from '@testing-library/react';
5
+ import { type ReactNode } from 'react';
6
+ import { AppSettingsProvider } from '../components/AppSettingsProvider.tsx';
7
+ import { useAppSetting } from './useAppSetting.ts';
8
+
9
+ const settings = { Api: { Missing: undefined, Timeout: 5000 } };
10
+
11
+ const createWrapper = ({ children }: { children: ReactNode }) => (
12
+ <AppSettingsProvider settings={settings}>{children}</AppSettingsProvider>
13
+ );
14
+
15
+ describe('useAppSetting', () => {
16
+ it('When used within a provider, then it returns the value at the requested leaf path', () => {
17
+ // Arrange
18
+ const path = 'Api.Timeout';
19
+
20
+ // Act
21
+ const { result } = renderHook(() => useAppSetting(path), { wrapper: createWrapper });
22
+
23
+ // Assert
24
+ expect(result.current).toBe(5000);
25
+ });
26
+
27
+ it('When the requested path is missing, then it returns undefined', () => {
28
+ // Arrange
29
+ const path = 'Api.Missing';
30
+
31
+ // Act
32
+ const { result } = renderHook(() => useAppSetting(path), { wrapper: createWrapper });
33
+
34
+ // Assert
35
+ expect(result.current).toBeUndefined();
36
+ });
37
+ });
@@ -0,0 +1,14 @@
1
+ import { type Get } from 'type-fest';
2
+ import { type AppSettingsSchema } from '../index.ts';
3
+ import { type SettingPath } from '../types/Config.types.ts';
4
+ import { useAppSettingsContext } from './useAppSettingsContext.ts';
5
+
6
+ export function useAppSetting<P extends SettingPath<AppSettingsSchema>>(path: P): Get<AppSettingsSchema, P> | undefined;
7
+ export function useAppSetting<T extends object, P extends SettingPath<T>>(path: P): Get<T, P> | undefined;
8
+
9
+ // Required for overloads
10
+ // eslint-disable-next-line prefer-arrow/prefer-arrow-functions
11
+ export function useAppSetting(path: string): unknown {
12
+ const settings = useAppSettingsContext();
13
+ return settings.read(path);
14
+ }
@@ -0,0 +1,37 @@
1
+ /** @jest-environment @repodog/jest-config/FailOnJsdomConsoleEnvironment.mjs */
2
+
3
+ import { describe, expect, it } from '@jest/globals';
4
+ import { renderHook } from '@testing-library/react';
5
+ import { type ReactNode } from 'react';
6
+ import { AppSettingsProvider } from '../components/AppSettingsProvider.tsx';
7
+ import { useAppSettings } from './useAppSettings.ts';
8
+
9
+ const settings = { Api: { Timeout: 5000 } };
10
+
11
+ const createWrapper = ({ children }: { children: ReactNode }) => (
12
+ <AppSettingsProvider settings={settings}>{children}</AppSettingsProvider>
13
+ );
14
+
15
+ describe('useAppSettings', () => {
16
+ it('When used within a provider, then it returns the requested object section', () => {
17
+ // Arrange
18
+ const path = 'Api';
19
+
20
+ // Act
21
+ const { result } = renderHook(() => useAppSettings(path), { wrapper: createWrapper });
22
+
23
+ // Assert
24
+ expect(result.current).toEqual({ Timeout: 5000 });
25
+ });
26
+
27
+ it('When the requested section is missing, then it returns undefined', () => {
28
+ // Arrange
29
+ const path = 'Missing';
30
+
31
+ // Act
32
+ const { result } = renderHook(() => useAppSettings(path), { wrapper: createWrapper });
33
+
34
+ // Assert
35
+ expect(result.current).toBeUndefined();
36
+ });
37
+ });
@@ -0,0 +1,17 @@
1
+ import { type Get } from 'type-fest';
2
+ import { type SettingSectionPath } from '#types/Config.types.ts';
3
+ import { type AppSettingsSchema } from '../index.ts';
4
+ import { useAppSettingsContext } from './useAppSettingsContext.ts';
5
+
6
+ export function useAppSettings<P extends SettingSectionPath<AppSettingsSchema>>(
7
+ path: P,
8
+ ): Get<AppSettingsSchema, P> | undefined;
9
+
10
+ export function useAppSettings<T extends object, P extends SettingSectionPath<T>>(path: P): Get<T, P> | undefined;
11
+
12
+ // Required for overloads
13
+ // eslint-disable-next-line prefer-arrow/prefer-arrow-functions
14
+ export function useAppSettings(path: string): unknown {
15
+ const settings = useAppSettingsContext();
16
+ return settings.read(path);
17
+ }