@meith/settings 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { ValidationError } from '@meith/core'
2
+
3
+ import type { SettingDefinition, SettingKey, SettingValue } from './definitions'
4
+ import { SETTING_DEFINITION_BY_KEY, SETTING_DEFINITIONS } from './definitions'
5
+
6
+ export interface SettingsRepository {
7
+ loadAll(): Promise<ReadonlyMap<string, string>>
8
+ save(entries: ReadonlyMap<string, string>): Promise<void>
9
+ delete(keys: readonly string[]): Promise<void>
10
+ }
11
+
12
+ export interface SettingsSnapshotOptions {
13
+ onInvalid?: (key: string, raw: string, reason: string) => void
14
+ }
15
+
16
+ function serialise(value: unknown): string {
17
+ if (typeof value === 'boolean') return value ? '1' : '0'
18
+ if (typeof value === 'number') return String(value)
19
+ if (typeof value === 'string') return value
20
+ return JSON.stringify(value)
21
+ }
22
+
23
+ function coerce(definition: SettingDefinition<unknown>, raw: string): unknown {
24
+ const expected = definition.default
25
+
26
+ let candidate: unknown = raw
27
+ if (typeof expected === 'boolean') {
28
+ candidate = raw === '1' || raw === 'true'
29
+ } else if (typeof expected === 'number') {
30
+ candidate = Number(raw)
31
+ } else if (typeof expected === 'object' && expected !== null) {
32
+ try {
33
+ candidate = JSON.parse(raw)
34
+ } catch {
35
+ candidate = raw
36
+ }
37
+ }
38
+
39
+ return definition.schema.parse(candidate)
40
+ }
41
+
42
+ export class SettingsSnapshot {
43
+ private constructor(private readonly values: ReadonlyMap<string, unknown>) {}
44
+
45
+ static fromOverrides(
46
+ overrides: ReadonlyMap<string, string>,
47
+ options: SettingsSnapshotOptions = {},
48
+ ): SettingsSnapshot {
49
+ const resolved = new Map<string, unknown>()
50
+
51
+ for (const definition of SETTING_DEFINITIONS) {
52
+ const raw = overrides.get(definition.key)
53
+
54
+ if (raw === undefined) {
55
+ resolved.set(definition.key, definition.default)
56
+ continue
57
+ }
58
+
59
+ try {
60
+ resolved.set(definition.key, coerce(definition as SettingDefinition<unknown>, raw))
61
+ } catch (error) {
62
+ options.onInvalid?.(
63
+ definition.key,
64
+ raw,
65
+ error instanceof Error ? error.message : String(error),
66
+ )
67
+ resolved.set(definition.key, definition.default)
68
+ }
69
+ }
70
+
71
+ return new SettingsSnapshot(resolved)
72
+ }
73
+
74
+ get<K extends SettingKey>(key: K): SettingValue<K> {
75
+ return this.values.get(key) as SettingValue<K>
76
+ }
77
+
78
+ toObject(): Record<string, unknown> {
79
+ return Object.fromEntries(this.values)
80
+ }
81
+ }
82
+
83
+ export interface SaveResult {
84
+ changed: string[]
85
+ invalidates: string[]
86
+ }
87
+
88
+ export async function saveSettings(
89
+ repository: SettingsRepository,
90
+ updates: Readonly<Record<string, unknown>>,
91
+ current: SettingsSnapshot,
92
+ ): Promise<SaveResult> {
93
+ const toWrite = new Map<string, string>()
94
+ const toDelete: string[] = []
95
+ const changed: string[] = []
96
+ const invalidates = new Set<string>()
97
+ const errors: string[] = []
98
+
99
+ for (const [key, value] of Object.entries(updates)) {
100
+ const definition = SETTING_DEFINITION_BY_KEY.get(key)
101
+
102
+ if (!definition) {
103
+ errors.push(`Unknown setting "${key}".`)
104
+ continue
105
+ }
106
+
107
+ const parsed = definition.schema.safeParse(value)
108
+ if (!parsed.success) {
109
+ errors.push(`${key}: ${parsed.error.issues.map((i) => i.message).join('; ')}`)
110
+ continue
111
+ }
112
+
113
+ if (Object.is(parsed.data, current.get(key as SettingKey))) {
114
+ continue
115
+ }
116
+
117
+ changed.push(key)
118
+ for (const tag of definition.invalidates ?? []) invalidates.add(tag)
119
+
120
+ if (Object.is(parsed.data, definition.default)) {
121
+ toDelete.push(key)
122
+ } else {
123
+ toWrite.set(key, serialise(parsed.data))
124
+ }
125
+ }
126
+
127
+ if (errors.length > 0) {
128
+ throw new ValidationError(`Invalid settings: ${errors.join(' ')}`)
129
+ }
130
+
131
+ if (toWrite.size > 0) await repository.save(toWrite)
132
+ if (toDelete.length > 0) await repository.delete(toDelete)
133
+
134
+ return { changed, invalidates: [...invalidates] }
135
+ }