@samitouri / QOSami-HFS / commits / 683a0c69

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 => {