dev: system to convert configs on updates
Massimo Melina committed
May 12, 2023 at 17:25 UTC
683a0c69774da225848aa9c7664b9e77487c195c
3 files changed
+81
-57
src/config.ts
+79
-55
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import EventEmitter from 'events'
4
-import { APP_PATH, argv, ORIGINAL_CWD } from './const'
4
+import { APP_PATH, argv, ORIGINAL_CWD, VERSION } from './const'
5
import { watchLoad } from './watchLoad'
6
import yaml from 'yaml'
7
import _ from 'lodash'
@@ -12,13 +12,14 @@ import events from './events'
12
13
const FILE = 'config.yaml'
14
15
-const configProps: Record<string, ConfigProps<any>> = {}
15
+// keep definition of config properties
16
+const configProps: Record<string, { defaultValue?: unknown }> = {}
17
18
let started = false // this will tell the difference for subscribeConfig()s that are called before or after config is loaded
18
-let state: Record<string, any> = {}
19
+let state: Record<string, any> = {} // current state of config properties
20
const cfgEvents = new EventEmitter()
21
cfgEvents.setMaxListeners(10_000)
21
-const path = with_(argv.config || process.env.HFS_CONFIG, p => {
22
+const filePath = with_(argv.config || process.env.HFS_CONFIG, p => {
23
if (!p)
24
return FILE
25
p = resolve(ORIGINAL_CWD, p)
@@ -29,46 +30,64 @@ const path = with_(argv.config || process.env.HFS_CONFIG, p => {
30
catch {}
31
return p
32
})
32
-console.log("config", path)
33
const legacyPosition = join(APP_PATH, FILE)
34
-if (!existsSync(path) && existsSync(legacyPosition))
34
+if (!existsSync(filePath) && existsSync(legacyPosition))
35
try {
36
- renameSync(legacyPosition, path)
36
+ renameSync(legacyPosition, filePath)
37
console.log("moved from legacy position", legacyPosition)
38
}
39
catch {
40
try { // attempt copying, in case moving the source file proves to be impractical
41
- copyFileSync(legacyPosition, path)
41
+ copyFileSync(legacyPosition, filePath)
42
console.log("copied from legacy position", legacyPosition)
43
}
44
catch {}
45
}
46
-const { save } = watchLoad(path, text => setConfig(yaml.parse(text)||{}, false), {
47
- failedOnFirstAttempt(){
48
- console.log("No config file, using defaults")
49
- setConfig({}, false)
50
- }
46
+// takes a semver like 1.2.3-alpha1, but alpha and beta numbers must share the number progression
47
+const versionToScalar = _.memoize((ver: string) => { // memoize so we don't have to care about converting same value twice
48
+ const [official, beta] = ver.split('-')
49
+ const numbers = official!.split('.').map(Number)
50
+ if (numbers.length !== 3)
51
+ return NaN
52
+ const officialScalar = numbers.reduce((acc,x,i) => acc + x * 1000 ** (2-i), 0) // 1000 gives 3 digits for each number
53
+ const betaScalar = 1 / (beta && Number(/\d+/.exec(beta)?.[0]) || Infinity) // beta tends to 0, while non-beta is 0
54
+ return officialScalar - betaScalar
55
})
56
53
-interface ConfigProps<T> {
54
- defaultValue?: T,
57
+class Version extends String {
58
+ olderThan(otherVersion: string) {
59
+ return versionToScalar(this.valueOf()) < versionToScalar(otherVersion)
60
+ }
61
}
56
-export function defineConfig<T, CT=T>(k: string, defaultValue: T, compiler: ((v: T) => CT)=_.identity) {
62
+
63
+const CONFIG_CHANGE_EVENT_PREFIX = 'new.'
64
+const currentVersion = new Version(VERSION)
65
+const configVersion = defineConfig('version', VERSION, v => new Version(v))
66
+
67
+type Subscriber<T,R=void> = (v:T, was:T | undefined, version: Version | undefined) => R
68
+export function defineConfig<T, CT=T>(k: string, defaultValue: T, compiler?: Subscriber<T,CT>) {
69
configProps[k] = { defaultValue }
70
type Updater = (currentValue:T) => T
59
- let compiled: CT = compiler(defaultValue)
60
- if (compiler)
61
- subscribeConfig(k, (v:T) =>
62
- compiled = compiler(v) )
63
- return {
71
+ let compiled = compiler?.(defaultValue, undefined, currentVersion)
72
+ const ret = { // consider a Class
73
key() {
74
return k
75
},
76
get(): T {
77
return getConfig(k)
78
},
70
- sub(cb: (v:T, was?:T)=>void) {
71
- return subscribeConfig(k, cb)
79
+ sub(cb: Subscriber<T>) {
80
+ if (started) // initial event already passed, we'll make the first call
81
+ cb(getConfig(k), defaultValue, configVersion.compiled())
82
+ const eventName = CONFIG_CHANGE_EVENT_PREFIX + k
83
+ return onOff(cfgEvents, {
84
+ [eventName]() {
85
+ if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
86
+ stack.push(cb) // @ts-ignore arguments
87
+ try { return cb.apply(this,arguments) }
88
+ finally { stack.pop() }
89
+ }
90
+ })
91
},
92
set(v: T | Updater) {
93
if (typeof v === 'function')
@@ -76,8 +95,15 @@ export function defineConfig<T, CT=T>(k: string, defaultValue: T, compiler: ((v:
95
else
96
setConfig1(k, v)
97
},
79
- compiled: () => compiled
98
+ compiled: () => {
99
+ if (!compiler) throw "missing compiler"
100
+ return compiled as CT
101
+ }
102
}
103
+ if (compiler)
104
+ ret.sub((...args) =>
105
+ compiled = compiler(...args) )
106
+ return ret
107
}
108
109
export function getConfigDefinition(k: string) {
@@ -85,19 +111,6 @@ export function getConfigDefinition(k: string) {
111
}
112
113
const stack: any[] = []
88
-function subscribeConfig<T>(k:string, cb: (v:T, was?:T)=>void) {
89
- if (started) // initial event already passed, we'll make the first call
90
- cb(getConfig(k))
91
- const eventName = 'new.'+k
92
- return onOff(cfgEvents, {
93
- [eventName]() {
94
- if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
95
- stack.push(cb) // @ts-ignore arguments
96
- try { return cb.apply(this,arguments) }
97
- finally { stack.pop() }
98
- }
99
- })
100
-}
114
115
export function getConfig(k:string) {
116
return state[k] ?? _.cloneDeep(configProps[k]?.defaultValue) // clone to avoid changing
@@ -114,18 +127,18 @@ export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[]
127
}
128
129
// pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs.
117
-export function setConfig(newCfg: Record<string,any>, save?: boolean) {
118
- if (!started) { // first time we consider also CLI args
119
- const argCfg = _.pickBy(newObj(configProps, (x, k) => argv[k]), x => x !== undefined)
120
- if (! _.isEmpty(argCfg)) {
121
- saveConfigAsap().then() // don't set `save` argument, as it would interfere below at check `save===false`
122
- Object.assign(newCfg, argCfg)
123
- }
130
+export function setConfig(newCfg: Record<string,unknown>, save?: boolean) {
131
+ const version = _.isString(newCfg.version) ? new Version(newCfg.version) : undefined
132
+ // first time we consider also CLI args
133
+ const argCfg = !started && _.pickBy(newObj(configProps, (x, k) => argv[k]), x => x !== undefined)
134
+ if (! _.isEmpty(argCfg)) {
135
+ saveConfigAsap() // don't set `save` argument, as it would interfere below at check `save===false`
136
+ Object.assign(newCfg, argCfg)
137
}
138
for (const k in newCfg)
139
apply(k, newCfg[k])
140
if (save) {
128
- saveConfigAsap().then()
141
+ saveConfigAsap()
142
return
143
}
144
if (started) {
@@ -138,35 +151,46 @@ export function setConfig(newCfg: Record<string,any>, save?: boolean) {
151
// first time we emit also for the default values
152
for (const k of Object.keys(configProps))
153
if (!newCfg.hasOwnProperty(k))
141
- apply(k, newCfg[k])
154
+ apply(k, newCfg[k], true)
155
started = true
156
events.emit('config ready')
157
+ if (version !== VERSION) // be sure to save version
158
+ saveConfigAsap()
159
145
- function apply(k: string, newV: any) {
146
- return setConfig1(k, newV, save === undefined)
160
+ function apply(k: string, newV: any, isDefault=false) {
161
+ return setConfig1(k, newV, save === undefined, argCfg && k in argCfg || isDefault ? currentVersion : version)
162
}
163
}
164
150
-function setConfig1(k: string, newV: any, saveChanges=true) {
165
+function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: Version) {
166
if (_.isPlainObject(newV))
152
- newV = _.pickBy(newV, x => x !== undefined)
167
+ newV = _.pickBy(newV as any, x => x !== undefined)
168
const def = configProps[k]?.defaultValue
169
if (same(newV ?? null, def ?? null))
170
newV = undefined
171
if (started && same(newV, state[k])) return // no change
172
const was = getConfig(k) // include cloned default, if necessary
173
state[k] = newV
159
- cfgEvents.emit('new.'+k, getConfig(k), was)
174
+ cfgEvents.emit(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion)
175
if (saveChanges)
161
- saveConfigAsap().then()
176
+ saveConfigAsap()
177
}
178
164
-export const saveConfigAsap = debounceAsync(async () => {
179
+const saveDebounced = debounceAsync(async () => {
180
while (!started)
181
await wait(100)
167
- let txt = yaml.stringify(state, { lineWidth:1000 })
182
+ let txt = yaml.stringify({ ...state, version: VERSION }, { lineWidth:1000 })
183
if (txt.trim() === '{}') // most users wouldn't understand
184
txt = ''
170
- save(path, txt)
185
+ save(filePath, txt)
186
.catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err)))
187
})
188
+export const saveConfigAsap = () => void(saveDebounced())
189
+
190
+console.log("config", filePath)
191
+const { save } = watchLoad(filePath, text => setConfig(yaml.parse(text)||{}, false), {
192
+ failedOnFirstAttempt(){
193
+ console.log("No config file, using defaults")
194
+ setConfig({}, false)
195
+ }
196
+})
\ No newline at end of file
src/log.ts
+1
-1
@@ -60,7 +60,7 @@ errorLogFile.sub(path => {
60
})
61
62
const logRotation = defineConfig('log_rotation', 'weekly')
63
-const dontLogNet = defineConfig('dont_log_net', '127.0.0.1|::1', makeNetMatcher)
63
+const dontLogNet = defineConfig('dont_log_net', '127.0.0.1|::1', v => makeNetMatcher(v))
64
65
export function log(): Koa.Middleware {
66
const debounce = _.debounce(cb => cb(), 1000)
src/perm.ts
+1
-1
@@ -83,7 +83,7 @@ export async function updateAccount(account: Account, changer?:Changer) {
83
saveAccountsAsap()
84
}
85
86
-const saveAccountsAsap = () => { saveConfigAsap().then() }
86
+const saveAccountsAsap = saveConfigAsap
87
88
export const accountsConfig = defineConfig('accounts', {} as Accounts)
89
accountsConfig.sub(obj => {