@grest-ts/config 0.0.5

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/src/GGConfig.d.ts +13 -0
  4. package/dist/src/GGConfig.d.ts.map +1 -0
  5. package/dist/src/GGConfig.js +58 -0
  6. package/dist/src/GGConfig.js.map +1 -0
  7. package/dist/src/GGConfigKey.d.ts +29 -0
  8. package/dist/src/GGConfigKey.d.ts.map +1 -0
  9. package/dist/src/GGConfigKey.js +79 -0
  10. package/dist/src/GGConfigKey.js.map +1 -0
  11. package/dist/src/GGConfigLocator.d.ts +35 -0
  12. package/dist/src/GGConfigLocator.d.ts.map +1 -0
  13. package/dist/src/GGConfigLocator.js +95 -0
  14. package/dist/src/GGConfigLocator.js.map +1 -0
  15. package/dist/src/GGConfigStore.d.ts +24 -0
  16. package/dist/src/GGConfigStore.d.ts.map +1 -0
  17. package/dist/src/GGConfigStore.js +89 -0
  18. package/dist/src/GGConfigStore.js.map +1 -0
  19. package/dist/src/GG_CONFIG.d.ts +4 -0
  20. package/dist/src/GG_CONFIG.d.ts.map +1 -0
  21. package/dist/src/GG_CONFIG.js +3 -0
  22. package/dist/src/GG_CONFIG.js.map +1 -0
  23. package/dist/src/assureValidConfigPath.d.ts +12 -0
  24. package/dist/src/assureValidConfigPath.d.ts.map +1 -0
  25. package/dist/src/assureValidConfigPath.js +35 -0
  26. package/dist/src/assureValidConfigPath.js.map +1 -0
  27. package/dist/src/index-node.d.ts +12 -0
  28. package/dist/src/index-node.d.ts.map +1 -0
  29. package/dist/src/index-node.js +12 -0
  30. package/dist/src/index-node.js.map +1 -0
  31. package/dist/src/keys/GGResource.d.ts +9 -0
  32. package/dist/src/keys/GGResource.d.ts.map +1 -0
  33. package/dist/src/keys/GGResource.js +14 -0
  34. package/dist/src/keys/GGResource.js.map +1 -0
  35. package/dist/src/keys/GGSecret.d.ts +9 -0
  36. package/dist/src/keys/GGSecret.d.ts.map +1 -0
  37. package/dist/src/keys/GGSecret.js +14 -0
  38. package/dist/src/keys/GGSecret.js.map +1 -0
  39. package/dist/src/keys/GGSetting.d.ts +11 -0
  40. package/dist/src/keys/GGSetting.d.ts.map +1 -0
  41. package/dist/src/keys/GGSetting.js +20 -0
  42. package/dist/src/keys/GGSetting.js.map +1 -0
  43. package/dist/src/stores/GGConfigStoreFile.d.ts +17 -0
  44. package/dist/src/stores/GGConfigStoreFile.d.ts.map +1 -0
  45. package/dist/src/stores/GGConfigStoreFile.js +62 -0
  46. package/dist/src/stores/GGConfigStoreFile.js.map +1 -0
  47. package/dist/src/stores/GGConfigStoreLocal.d.ts +54 -0
  48. package/dist/src/stores/GGConfigStoreLocal.d.ts.map +1 -0
  49. package/dist/src/stores/GGConfigStoreLocal.js +76 -0
  50. package/dist/src/stores/GGConfigStoreLocal.js.map +1 -0
  51. package/dist/src/tsconfig.json +16 -0
  52. package/dist/testkit/GGConfigCommands.d.ts +18 -0
  53. package/dist/testkit/GGConfigCommands.d.ts.map +1 -0
  54. package/dist/testkit/GGConfigCommands.js +42 -0
  55. package/dist/testkit/GGConfigCommands.js.map +1 -0
  56. package/dist/testkit/GGConfigTestComponent.d.ts +9 -0
  57. package/dist/testkit/GGConfigTestComponent.d.ts.map +1 -0
  58. package/dist/testkit/GGConfigTestComponent.js +20 -0
  59. package/dist/testkit/GGConfigTestComponent.js.map +1 -0
  60. package/dist/testkit/GGConfigTestStore.d.ts +23 -0
  61. package/dist/testkit/GGConfigTestStore.d.ts.map +1 -0
  62. package/dist/testkit/GGConfigTestStore.js +85 -0
  63. package/dist/testkit/GGConfigTestStore.js.map +1 -0
  64. package/dist/testkit/GGTestSelectorConfig.d.ts +22 -0
  65. package/dist/testkit/GGTestSelectorConfig.d.ts.map +1 -0
  66. package/dist/testkit/GGTestSelectorConfig.js +57 -0
  67. package/dist/testkit/GGTestSelectorConfig.js.map +1 -0
  68. package/dist/testkit/index-testkit.d.ts +5 -0
  69. package/dist/testkit/index-testkit.d.ts.map +1 -0
  70. package/dist/testkit/index-testkit.js +5 -0
  71. package/dist/testkit/index-testkit.js.map +1 -0
  72. package/dist/tsconfig.publish.tsbuildinfo +1 -0
  73. package/package.json +59 -0
  74. package/src/GGConfig.ts +70 -0
  75. package/src/GGConfigKey.ts +107 -0
  76. package/src/GGConfigLocator.ts +112 -0
  77. package/src/GGConfigStore.ts +100 -0
  78. package/src/GG_CONFIG.ts +4 -0
  79. package/src/assureValidConfigPath.spec.ts +140 -0
  80. package/src/assureValidConfigPath.ts +34 -0
  81. package/src/index-node.ts +11 -0
  82. package/src/keys/GGResource.ts +20 -0
  83. package/src/keys/GGSecret.ts +20 -0
  84. package/src/keys/GGSetting.ts +28 -0
  85. package/src/stores/GGConfigStoreFile.ts +69 -0
  86. package/src/stores/GGConfigStoreLocal.ts +110 -0
  87. package/src/tsconfig.json +16 -0
@@ -0,0 +1,112 @@
1
+ import {GGConfigStore} from "./GGConfigStore";
2
+ import {GGConfigKey, GGConfigKeyConstructor} from "./GGConfigKey";
3
+ import {GGLocator, GGLocatorServiceType} from "@grest-ts/locator";
4
+ import {GG_CONFIG} from "./GG_CONFIG";
5
+ import {ConfigValues, GGConfigStoreLocal} from "./stores/GGConfigStoreLocal";
6
+
7
+ export type GGConfigDefinition<T> = T & {
8
+ __isGGConfigDefinition: never,
9
+ __getKeysMap: () => ReadonlyMap<GGConfigKeyConstructor, GGConfigKey[]>
10
+ };
11
+
12
+ export class GGConfigLocator<T extends object> {
13
+
14
+ #isStarted = false;
15
+ readonly #config: GGConfigDefinition<T>;
16
+ readonly #storesList: GGConfigStore<GGConfigKey>[] = []
17
+ readonly #storesMap: Map<string, GGConfigStore<GGConfigKey>> = new Map()
18
+
19
+ protected readonly localConfig: GGConfigStoreLocal<T, any> = undefined
20
+
21
+ /**
22
+ * @param config - Config definition.
23
+ * @param localConfig - Optional local config values. If provided, stores will use this instead of the real values when not running in production.
24
+ * new GGConfigLocator(MyConfig, localConfig) is shorthand for this: new GGConfigLocator(MyConfig).add(AllKeys, new GGConfigStoreLocal(MyConfig, localConfig))
25
+ */
26
+ constructor(config: GGConfigDefinition<T>, localConfig?: ConfigValues<T>) {
27
+ if (!config) throw new Error("Config definition is required");
28
+ this.#config = config;
29
+ this.localConfig = localConfig ? new GGConfigStoreLocal(config, localConfig) : undefined
30
+ GGLocator.getScope().setWithLifecycle(GG_CONFIG, this, {
31
+ type: GGLocatorServiceType.CONFIG,
32
+ start: () => this.start(),
33
+ teardown: () => this.teardown()
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Can override this if you want custom handling.
39
+ */
40
+ public onNotifyError = (error: Error): never => {
41
+ throw error;
42
+ }
43
+
44
+ /**
45
+ * Add a config store for a key type.
46
+ *
47
+ * Note for framework development: Testkit overrides this function.
48
+ */
49
+ public add<Key extends GGConfigKey>(key: GGConfigKeyConstructor<Key> | GGConfigKeyConstructor<Key>[], store: GGConfigStore<Key>): this {
50
+ return this._add(key, this._useLocalStoreIfNeeded(store))
51
+ }
52
+
53
+ protected _useLocalStoreIfNeeded<Key extends GGConfigKey>(store: GGConfigStore<Key>): GGConfigStore<Key> {
54
+ if (process.env.NODE_ENV === "production" || !this.localConfig) {
55
+ return store;
56
+ } else {
57
+ return this.localConfig;
58
+ }
59
+ }
60
+
61
+ protected _add<Key extends GGConfigKey>(key: GGConfigKeyConstructor<Key> | GGConfigKeyConstructor<Key>[], store: GGConfigStore<Key>): this {
62
+ if (this.#isStarted) {
63
+ throw new Error("Cannot add store after config holder is started");
64
+ }
65
+ const keyTypes = Array.isArray(key) ? key : [key];
66
+ const keysMap = this.#config.__getKeysMap();
67
+ for (const keyType of keyTypes) {
68
+ const keysForType = (keysMap.get(keyType) ?? []) as Key[];
69
+ store.setKeys(keysForType);
70
+ this.#storesMap.set(keyType.NAME, store as GGConfigStore<GGConfigKey>);
71
+ }
72
+ if (!this.#storesList.includes(store as GGConfigStore<GGConfigKey>)) {
73
+ this.#storesList.push(store as GGConfigStore<GGConfigKey>);
74
+ }
75
+ return this;
76
+ }
77
+
78
+ public getStore<Key extends GGConfigKey>(keyType: GGConfigKeyConstructor<Key>): GGConfigStore<Key> {
79
+ return this.getStoreByConfigKeyName(keyType.NAME);
80
+ }
81
+
82
+ public getStoreByConfigKeyName<Key extends GGConfigKey>(name: string): GGConfigStore<Key> {
83
+ const store = this.#storesMap.get(name);
84
+ if (!store) {
85
+ throw new Error(`No store for store key: ${name ?? 'undefined'}.`);
86
+ }
87
+ return store as GGConfigStore<Key>
88
+ }
89
+
90
+ public getStores(): GGConfigStore<GGConfigKey>[] {
91
+ return this.#storesList;
92
+ }
93
+
94
+ public async start(): Promise<void> {
95
+ if (this.#isStarted) {
96
+ throw new Error("Already started");
97
+ }
98
+ this.#isStarted = true;
99
+ this.#config.__getKeysMap().forEach((_, keyType) => {
100
+ if (!this.#storesMap.has(keyType.NAME)) {
101
+ throw new Error(`Missing stores for config key type '${keyType.NAME}'`);
102
+ }
103
+ })
104
+ Object.freeze(this.#storesMap);
105
+ Object.freeze(this.#storesList);
106
+ await Promise.all(this.#storesList.map((store) => store.start()));
107
+ }
108
+
109
+ public async teardown(): Promise<void> {
110
+ await Promise.all(this.#storesList.map(s => s.teardown()));
111
+ }
112
+ }
@@ -0,0 +1,100 @@
1
+ import {GGConfigKey} from "./GGConfigKey";
2
+ import {GG_CONFIG} from "./GG_CONFIG";
3
+
4
+ export type ConfigUpdateCallback = (value: any) => void | Promise<void>;
5
+
6
+ /**
7
+ * Important when extending: Use resolveValue() to resolve and validate config values.
8
+ */
9
+ export abstract class GGConfigStore<Key extends GGConfigKey> {
10
+
11
+ #keys: Key[] = []
12
+ public get keys(): readonly Key[] { return this.#keys }
13
+ readonly #watchers: Map<GGConfigKey, ConfigUpdateCallback[]> = new Map()
14
+
15
+ protected isStarted = false;
16
+
17
+ public get started(): boolean {
18
+ return this.isStarted;
19
+ }
20
+
21
+ public setKeys(keys: readonly Key[]): void {
22
+ if (this.isStarted) {
23
+ throw new Error(`Cannot set keys after store is started: ${this.constructor.name}`);
24
+ }
25
+ this.#keys.push(...keys);
26
+ }
27
+
28
+ public abstract getValue<T>(key: GGConfigKey<T>): T
29
+
30
+ /**
31
+ * Resolves the final value for a config key: uses storeValue if present, falls back to key.getDefault(),
32
+ * then validates against schema. If the resolved value is undefined and the schema doesn't allow it,
33
+ * validation will fail — this is how missing required keys are caught.
34
+ */
35
+ protected resolveValue<T>(key: GGConfigKey<T>, storeValue: unknown, isInitialLoad: boolean = false): T {
36
+ const val = storeValue !== undefined ? storeValue : key.getDefault();
37
+ const check = key.schema.safeParse(val);
38
+ if (check.success === false) {
39
+ const errors = JSON.stringify(check.issues.toJSON(), null, 2).replaceAll("\n", "\n\t\t")
40
+ throw new Error(`Config validation failed for "${key.name}" during ${isInitialLoad ? "initial-load" : "reload"}.\n\tDefined at:\n\t\t${key.definedAt}\n\tIssues:\n\t\t${errors}`);
41
+ } else {
42
+ return check.value as T;
43
+ }
44
+ }
45
+
46
+ public async start(): Promise<void> {
47
+ if (this.isStarted === true) {
48
+ throw new Error(`Config store already started: ${this.constructor.name}`);
49
+ }
50
+ this.isStarted = true;
51
+ Object.freeze(this.#keys);
52
+
53
+ this.keys.forEach(key => {
54
+ this.getValue(key); // Get every value so that config values are definitely initialized (or they would throw)
55
+ });
56
+ }
57
+
58
+ public async teardown(): Promise<void> {
59
+ this.#watchers.clear();
60
+ this.isStarted = false;
61
+ }
62
+
63
+ public watch(key: GGConfigKey, callback: ConfigUpdateCallback): () => void {
64
+ if (!this.#watchers.has(key)) {
65
+ this.#watchers.set(key, []);
66
+ }
67
+ this.#watchers.get(key)!.push(callback);
68
+ return () => {
69
+ const list = this.#watchers.get(key);
70
+ if (!list) return;
71
+ const index = list.indexOf(callback);
72
+ if (index >= 0) list.splice(index, 1);
73
+ };
74
+ }
75
+
76
+ public async notify(key: GGConfigKey) {
77
+ if (this.#watchers.has(key)) {
78
+ const promises: Promise<void>[] = [];
79
+ const newValue = this.getValue(key);
80
+ this.#watchers.get(key).forEach(callback => {
81
+ try {
82
+ const result = callback(newValue);
83
+ if (result instanceof Promise) {
84
+ promises.push(result);
85
+ }
86
+ } catch (err) {
87
+ GG_CONFIG.get().onNotifyError(err);
88
+ }
89
+ })
90
+ if (promises.length > 0) {
91
+ const results = await Promise.allSettled(promises);
92
+ for (const result of results) {
93
+ if (result.status === 'rejected') {
94
+ GG_CONFIG.get().onNotifyError(result.reason);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,4 @@
1
+ import {GGLocatorKey} from "@grest-ts/locator";
2
+ import type {GGConfigLocator} from "./GGConfigLocator";
3
+
4
+ export const GG_CONFIG = new GGLocatorKey<GGConfigLocator<any>>("GGConfig");
@@ -0,0 +1,140 @@
1
+ import {describe, it, expect} from 'vitest';
2
+ import {assureValidConfigPath} from './assureValidConfigPath';
3
+
4
+ describe('assureValidConfigPath', () => {
5
+
6
+ describe('valid paths', () => {
7
+ it('accepts simple path', () => {
8
+ expect(() => assureValidConfigPath('/app/setting')).not.toThrow();
9
+ });
10
+
11
+ it('accepts path with multiple segments', () => {
12
+ expect(() => assureValidConfigPath('/app/db/host')).not.toThrow();
13
+ });
14
+
15
+ it('accepts path with underscores', () => {
16
+ expect(() => assureValidConfigPath('/my_app/my_setting')).not.toThrow();
17
+ });
18
+
19
+ it('accepts path with numbers after letter', () => {
20
+ expect(() => assureValidConfigPath('/app1/setting2')).not.toThrow();
21
+ });
22
+
23
+ it('accepts mixed valid characters', () => {
24
+ expect(() => assureValidConfigPath('/my_app_v2/db_connection_pool_size')).not.toThrow();
25
+ });
26
+
27
+ it('accepts uppercase letters', () => {
28
+ expect(() => assureValidConfigPath('/MyApp/DbHost')).not.toThrow();
29
+ });
30
+
31
+ it('accepts single segment', () => {
32
+ expect(() => assureValidConfigPath('/setting')).not.toThrow();
33
+ });
34
+ });
35
+
36
+ describe('must start with slash', () => {
37
+ it('rejects path without leading slash', () => {
38
+ expect(() => assureValidConfigPath('app/setting')).toThrow("must start with '/'");
39
+ });
40
+
41
+ it('rejects single word without slash', () => {
42
+ expect(() => assureValidConfigPath('setting')).toThrow("must start with '/'");
43
+ });
44
+ });
45
+
46
+ describe('cannot end with slash', () => {
47
+ it('rejects path with trailing slash', () => {
48
+ expect(() => assureValidConfigPath('/app/setting/')).toThrow("cannot end with '/'");
49
+ });
50
+
51
+ it('rejects root path (just slash)', () => {
52
+ expect(() => assureValidConfigPath('/')).toThrow("cannot end with '/'");
53
+ });
54
+ });
55
+
56
+ describe('no double slashes', () => {
57
+ it('rejects double slash in middle', () => {
58
+ expect(() => assureValidConfigPath('/app//setting')).toThrow("cannot contain '//'");
59
+ });
60
+
61
+ it('rejects double slash at start', () => {
62
+ expect(() => assureValidConfigPath('//app/setting')).toThrow("cannot contain '//'");
63
+ });
64
+
65
+ it('rejects multiple double slashes', () => {
66
+ expect(() => assureValidConfigPath('/app//db//host')).toThrow("cannot contain '//'");
67
+ });
68
+ });
69
+
70
+ describe('invalid characters', () => {
71
+ it('rejects dots', () => {
72
+ expect(() => assureValidConfigPath('/app/db.host')).toThrow('invalid characters');
73
+ });
74
+
75
+ it('rejects hyphens', () => {
76
+ expect(() => assureValidConfigPath('/my-app/setting')).toThrow('invalid characters');
77
+ });
78
+
79
+ it('rejects spaces', () => {
80
+ expect(() => assureValidConfigPath('/app/my setting')).toThrow('invalid characters');
81
+ });
82
+
83
+ it('rejects special characters @', () => {
84
+ expect(() => assureValidConfigPath('/app@host/setting')).toThrow('invalid characters');
85
+ });
86
+
87
+ it('rejects special characters #', () => {
88
+ expect(() => assureValidConfigPath('/app#1/setting')).toThrow('invalid characters');
89
+ });
90
+
91
+ it('rejects special characters $', () => {
92
+ expect(() => assureValidConfigPath('/app/$setting')).toThrow('invalid characters');
93
+ });
94
+
95
+ it('rejects special characters %', () => {
96
+ expect(() => assureValidConfigPath('/app/100%')).toThrow('invalid characters');
97
+ });
98
+
99
+ it('rejects colon', () => {
100
+ expect(() => assureValidConfigPath('/app:setting')).toThrow('invalid characters');
101
+ });
102
+
103
+ it('rejects backslash', () => {
104
+ expect(() => assureValidConfigPath('/app\\setting')).toThrow('invalid characters');
105
+ });
106
+ });
107
+
108
+ describe('segments must start with letter', () => {
109
+ it('rejects segment starting with number', () => {
110
+ expect(() => assureValidConfigPath('/app/1setting')).toThrow('must start with a letter');
111
+ });
112
+
113
+ it('rejects segment starting with underscore', () => {
114
+ expect(() => assureValidConfigPath('/app/_setting')).toThrow('must start with a letter');
115
+ });
116
+
117
+ it('rejects first segment starting with number', () => {
118
+ expect(() => assureValidConfigPath('/1app/setting')).toThrow('must start with a letter');
119
+ });
120
+
121
+ it('accepts segment with numbers after first letter', () => {
122
+ expect(() => assureValidConfigPath('/app/s1e2t3')).not.toThrow();
123
+ });
124
+ });
125
+
126
+ describe('max length', () => {
127
+ it('accepts path at exactly 2048 characters', () => {
128
+ const path = '/' + 'a'.repeat(2047);
129
+ expect(path.length).toBe(2048);
130
+ expect(() => assureValidConfigPath(path)).not.toThrow();
131
+ });
132
+
133
+ it('rejects path exceeding 2048 characters', () => {
134
+ const path = '/' + 'a'.repeat(2048);
135
+ expect(path.length).toBe(2049);
136
+ expect(() => assureValidConfigPath(path)).toThrow('exceeds 2048 characters');
137
+ });
138
+ });
139
+
140
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Validates config key name for compatibility with external providers.
3
+ * Rules:
4
+ * - Must start with forward slash
5
+ * - Only letters, numbers, underscore, forward slash allowed
6
+ * - Each segment (word) must start with a letter
7
+ * - No double slashes
8
+ * - Cannot end with slash
9
+ * - Max 2048 characters (AWS limit)
10
+ */
11
+ export function assureValidConfigPath(path: string) {
12
+ if (path.length > 2048) {
13
+ throw new Error(`Config key name exceeds 2048 characters: ${path}`);
14
+ }
15
+ if (!path.startsWith('/')) {
16
+ throw new Error(`Config key name must start with '/': ${path}`);
17
+ }
18
+ if (path.endsWith('/')) {
19
+ throw new Error(`Config key name cannot end with '/': ${path}`);
20
+ }
21
+ if (path.includes('//')) {
22
+ throw new Error(`Config key name cannot contain '//': ${path}`);
23
+ }
24
+ if (!/^[a-zA-Z0-9_\/]+$/.test(path)) {
25
+ throw new Error(`Config key name contains invalid characters (allowed: a-z, A-Z, 0-9, _, /): ${path}`);
26
+ }
27
+ // Each segment must start with a letter
28
+ const segments = path.split('/').filter(s => s.length > 0);
29
+ for (const segment of segments) {
30
+ if (!/^[a-zA-Z]/.test(segment)) {
31
+ throw new Error(`Each path segment must start with a letter: ${path}`);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,11 @@
1
+ export * from './GGConfig';
2
+ export * from './GG_CONFIG';
3
+ export * from './GGConfigLocator';
4
+ export * from "./GGConfigKey";
5
+ export * from "./GGConfigStore";
6
+ export * from "./stores/GGConfigStoreFile";
7
+ export * from "./stores/GGConfigStoreLocal";
8
+ export * from "./keys/GGSetting";
9
+ export * from "./keys/GGSecret";
10
+ export * from "./keys/GGResource";
11
+ export {assureValidConfigPath} from "./assureValidConfigPath";
@@ -0,0 +1,20 @@
1
+ import {GGConfigKey} from "../GGConfigKey";
2
+ import {GGValidator} from "@grest-ts/schema";
3
+
4
+ export class GGResource<T> extends GGConfigKey<T> {
5
+
6
+ public static readonly NAME = "[GGResource]";
7
+
8
+ constructor(name: string, schema: GGValidator<T>, description: string) {
9
+ super(name, schema, description);
10
+ }
11
+
12
+ public getStoreKey(): string {
13
+ return GGResource.NAME;
14
+ }
15
+
16
+ public get(): T {
17
+ return this.getValue();
18
+ }
19
+
20
+ }
@@ -0,0 +1,20 @@
1
+ import {GGConfigKey} from "../GGConfigKey";
2
+ import {GGValidator} from "@grest-ts/schema";
3
+
4
+ export class GGSecret<T> extends GGConfigKey<T> {
5
+
6
+ public static readonly NAME = "[GGSecret]";
7
+
8
+ constructor(name: string, schema: GGValidator<T>, description: string) {
9
+ super(name, schema, description);
10
+ }
11
+
12
+ public getStoreKey(): string {
13
+ return GGSecret.NAME
14
+ }
15
+
16
+ public reveal(): T {
17
+ return this.getValue();
18
+ }
19
+
20
+ }
@@ -0,0 +1,28 @@
1
+ import {GGConfigKey, Widen} from "../GGConfigKey";
2
+ import {deepFreeze} from "@grest-ts/common";
3
+ import {GGValidator} from "@grest-ts/schema";
4
+
5
+ export class GGSetting<T> extends GGConfigKey<T> {
6
+
7
+ public static readonly NAME = "[GGSetting]";
8
+
9
+ readonly #default: T;
10
+
11
+ constructor(name: string, schema: GGValidator<T>, defaultValue: Widen<T>, description: string) {
12
+ super(name, schema, description);
13
+ this.#default = deepFreeze(defaultValue as T);
14
+ }
15
+
16
+ public override getDefault(): T {
17
+ return this.#default;
18
+ }
19
+
20
+ public getStoreKey(): string {
21
+ return GGSetting.NAME
22
+ }
23
+
24
+ public get(): T {
25
+ return this.getValue();
26
+ }
27
+
28
+ }
@@ -0,0 +1,69 @@
1
+ import * as fs from 'fs';
2
+ import {GGConfigStore} from "../GGConfigStore";
3
+ import {GGConfigKey} from "../GGConfigKey";
4
+ import {dirname, join} from "node:path";
5
+ import {fileURLToPath} from "node:url";
6
+
7
+ /**
8
+ * Local file-based settings strategy.
9
+ * Reads settings from settings.json in the config directory.
10
+ * Watches file for changes and automatically reloads.
11
+ */
12
+ export class GGConfigStoreFile<Key extends GGConfigKey> extends GGConfigStore<Key> {
13
+
14
+ private readonly file: string;
15
+ #watcher: fs.FSWatcher | null = null;
16
+ #debounceTimer: NodeJS.Timeout | null = null;
17
+ #valuesCache: Map<GGConfigKey, unknown> = new Map();
18
+
19
+ constructor(file: string, moduleUrl?: string) {
20
+ super();
21
+ const prefix = moduleUrl ? dirname(fileURLToPath(moduleUrl)) : "";
22
+ this.file = join(prefix, file);
23
+ }
24
+
25
+ public override async start(): Promise<void> {
26
+ await super.start();
27
+ await this.refresh(true);
28
+ this.#watcher = fs.watch(this.file, (eventType) => {
29
+ if (eventType === 'change') {
30
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
31
+ // Adding debounce so file writes would have time to correctly complete.
32
+ this.#debounceTimer = setTimeout(() => this.refresh(false), 100);
33
+ }
34
+ });
35
+ }
36
+
37
+ public override async teardown(): Promise<void> {
38
+ if (this.#watcher) {
39
+ this.#watcher.close();
40
+ this.#watcher = null;
41
+ }
42
+ await super.teardown();
43
+ }
44
+
45
+ public async refresh(isInitialLoad: boolean): Promise<void> {
46
+ const fileContent = fs.readFileSync(this.file, 'utf-8');
47
+ if (!fileContent) {
48
+ throw new Error("Settings file is empty! Why is that?")
49
+ }
50
+ const fileJson = JSON.parse(fileContent);
51
+ this.#valuesCache.clear();
52
+ this.keys.forEach(key => {
53
+ const path = key.name.split("/");
54
+ let val: any = undefined;
55
+ if (path.length > 1) {
56
+ val = fileJson;
57
+ for (let i = 1; i < path.length; i++) {
58
+ val = val?.[path[i]]
59
+ }
60
+ }
61
+ this.#valuesCache.set(key, this.resolveValue(key, val, isInitialLoad));
62
+ });
63
+ }
64
+
65
+ public getValue<T>(key: GGConfigKey<T>): T {
66
+ return this.#valuesCache.get(key) as T;
67
+ }
68
+
69
+ }
@@ -0,0 +1,110 @@
1
+ import {GGConfigStore} from "../GGConfigStore";
2
+ import {GGConfigKey, Widen} from "../GGConfigKey";
3
+ import {deepFreeze} from "@grest-ts/common";
4
+
5
+ /**
6
+ * Extracts the keys from T that are GGConfigKey instances or objects containing them.
7
+ * Uses a depth limit (D) to prevent infinite recursion on circular types (e.g. GGSchema).
8
+ */
9
+ type ConfigKeyOf<T, D extends 1[] = []> = D['length'] extends 5 ? never : {
10
+ [K in keyof T]: [T[K]] extends [never] ? never :
11
+ T[K] extends GGConfigKey<any> ? K :
12
+ T[K] extends Function ? never :
13
+ T[K] extends object ? (ConfigKeyOf<T[K], [...D, 1]> extends never ? never : K) : never
14
+ }[keyof T];
15
+
16
+ /**
17
+ * Maps a config definition to the shape of values it expects.
18
+ * GGConfigKey<V> → V, objects with config keys → recurse, everything else → excluded.
19
+ */
20
+ export type ConfigValues<T, D extends 1[] = []> =
21
+ D['length'] extends 5 ? never :
22
+ T extends GGConfigKey<infer V> ? Widen<V> :
23
+ T extends object ? (
24
+ ConfigKeyOf<T, D> extends never ? never : { [K in ConfigKeyOf<T, D>]: ConfigValues<T[K], [...D, 1]> }
25
+ ) : never;
26
+
27
+ /**
28
+ * Type-checks a local config values object against a config definition.
29
+ * Returns the values object as-is — this is a compile-time helper, not a store.
30
+ *
31
+ * Use with GGConfigStoreLocal in your runtime's compose():
32
+ * @example
33
+ * ```typescript
34
+ * // config/local.ts — just data:
35
+ * export default createLocalConfig(MyConfig, {
36
+ * mysql: {
37
+ * host: { host: "localhost", port: 3306, database: "mydb" },
38
+ * user: { username: "root", password: "root" },
39
+ * },
40
+ * jwtSecret: "dev-secret",
41
+ * })
42
+ *
43
+ * // runtime.ts — fresh store per compose():
44
+ * import localConfig from "./config/local.js";
45
+ * new GGConfigLocator(MyConfig)
46
+ * .add([GGResource, GGSecret], new GGConfigStoreLocal(MyConfig, localConfig))
47
+ * ```
48
+ */
49
+ export function createLocalConfig<T extends object>(_config: T, values: ConfigValues<T>): ConfigValues<T> {
50
+ deepFreeze(values)
51
+ return values;
52
+ }
53
+
54
+ /**
55
+ * Type-safe local development config store.
56
+ * Refuses to start in production — crashes immediately if NODE_ENV is "production".
57
+ * Use createLocalConfig() for the best DX, or .set() for manual control.
58
+ */
59
+ export class GGConfigStoreLocal<Struct extends object, Key extends GGConfigKey = GGConfigKey> extends GGConfigStore<Key> {
60
+
61
+ readonly #values = new Map<GGConfigKey, unknown>();
62
+ readonly #valuesCache = new Map<GGConfigKey, unknown>();
63
+
64
+ constructor(config: Struct, values: ConfigValues<Struct>) {
65
+ super();
66
+ walkAndSet(this, config, values);
67
+ }
68
+
69
+ public set<T>(key: GGConfigKey<T>, value: T): this {
70
+ this.#values.set(key, value);
71
+ return this;
72
+ }
73
+
74
+ public override async start(): Promise<void> {
75
+ if (process.env.NODE_ENV === "production") {
76
+ throw new Error(
77
+ "GGConfigStoreLocal cannot be used in production. " +
78
+ "Use GGConfigStoreAwsSecretsManager or another production-safe store."
79
+ );
80
+ }
81
+
82
+ this.keys.forEach(key => {
83
+ this.#valuesCache.set(key, this.resolveValue(key, this.#values.get(key), true));
84
+ });
85
+
86
+ await super.start();
87
+ }
88
+
89
+ public override async teardown(): Promise<void> {
90
+ this.#valuesCache.clear();
91
+ await super.teardown();
92
+ }
93
+
94
+ public getValue<T>(key: GGConfigKey<T>): T {
95
+ return this.#valuesCache.get(key) as T;
96
+ }
97
+
98
+ }
99
+
100
+ function walkAndSet(store: GGConfigStoreLocal<any>, config: any, values: any) {
101
+ for (const prop of Object.keys(values)) {
102
+ const configEntry = config[prop];
103
+ const value = values[prop];
104
+ if (configEntry instanceof GGConfigKey) {
105
+ store.set(configEntry, value);
106
+ } else if (configEntry != null && typeof configEntry === 'object' && value != null && typeof value === 'object') {
107
+ walkAndSet(store, configEntry, value);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "//": "THIS FILE IS GENERATED - DO NOT EDIT",
3
+ "extends": "../../../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "rootDir": ".",
6
+ "lib": [
7
+ "ES2022"
8
+ ],
9
+ "types": [
10
+ "node"
11
+ ]
12
+ },
13
+ "include": [
14
+ "**/*"
15
+ ]
16
+ }