Skip to content

Commit af2aac5

Browse files
authored
Fix selected dependencies going stale
Always fill in selected dependencies on the backend, ensuring that these are either an empty object if an app does not have dependencies, or, if it has, match the app's dependencies, both when stored and returned. Also hardens FileStore to recover from a faulty store file.
1 parent 16a9a8b commit af2aac5

File tree

6 files changed

+63
-25
lines changed

6 files changed

+63
-25
lines changed

packages/ui/src/modules/app-store/app-page/app-settings-dialog.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ export function AppSettingsDialog() {
4747
}
4848

4949
function areSelectionsEqual(a?: Record<string, string>, b?: Record<string, string>) {
50-
if (!a || !b || a === b) return true
51-
const keys1 = Object.keys(a)
52-
const keys2 = Object.keys(b)
50+
if (a === b) return true
51+
const keys1 = Object.keys((a ||= {}))
52+
const keys2 = Object.keys((b ||= {}))
5353
if (keys1.length !== keys2.length) return false
5454
for (const key of keys1) {
5555
if (b[key] !== a[key]) return false
@@ -71,9 +71,7 @@ function AppSettingsDialogForApp({
7171
openDependency?: string
7272
}) {
7373
const dialogProps = useDialogOpenProps('app-settings')
74-
const [selectedDependencies, setSelectedDependencies] = useState<Record<string, string>>(
75-
app.selectedDependencies ?? {},
76-
)
74+
const [selectedDependencies, setSelectedDependencies] = useState(app.selectedDependencies)
7775
const [hadChanges, setHadChanges] = useState(false)
7876
const ctx = trpcReact.useContext()
7977
const setSelectedDependenciesMut = trpcReact.apps.setSelectedDependencies.useMutation({

packages/umbreld/source/modules/apps/app.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ import pRetry from 'p-retry'
1111
import getDirectorySize from '../utilities/get-directory-size.js'
1212
import {pullAll} from '../utilities/docker-pull.js'
1313
import FileStore from '../utilities/file-store.js'
14-
14+
import {fillSelectedDependencies} from '../utilities/dependencies.js'
1515
import type Umbreld from '../../index.js'
1616
import {validateManifest, type AppSettings} from './schema.js'
17-
1817
import appScript from './legacy-compat/app-script.js'
1918

2019
async function readYaml(path: string) {
@@ -365,20 +364,18 @@ export default class App {
365364

366365
// Get the app's selected dependencies
367366
async getSelectedDependencies() {
368-
return this.store.get('dependencies')
367+
const [{dependencies}, selectedDependencies] = await Promise.all([
368+
this.readManifest(),
369+
this.store.get('dependencies'),
370+
])
371+
return fillSelectedDependencies(dependencies, selectedDependencies)
369372
}
370373

371374
// Set the app's selected dependencies
372375
async setSelectedDependencies(selectedDependencies: Record<string, string>) {
373376
const {dependencies} = await this.readManifest()
374-
const selections = (dependencies ?? []).reduce(
375-
(selections, dependencyId) => {
376-
selections[dependencyId] = selectedDependencies[dependencyId] ?? dependencyId
377-
return selections
378-
},
379-
{} as Record<string, string>,
380-
)
381-
const success = await this.store.set('dependencies', selections)
377+
const filledSelectedDependencies = fillSelectedDependencies(dependencies, selectedDependencies)
378+
const success = await this.store.set('dependencies', filledSelectedDependencies)
382379
if (success) {
383380
this.restart().catch((error) => {
384381
this.logger.error(`Failed to restart '${this.id}': ${error.message}`)

packages/umbreld/source/modules/apps/apps.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import semver from 'semver'
99
import randomToken from '../../modules/utilities/random-token.js'
1010
import type Umbreld from '../../index.js'
1111
import appEnvironment from './legacy-compat/app-environment.js'
12-
1312
import type {AppSettings} from './schema.js'
1413
import App, {readManifestInDirectory} from './app.js'
1514
import type {AppManifest} from './schema.js'
15+
import {fillSelectedDependencies} from '../utilities/dependencies.js'
1616

1717
export default class Apps {
1818
#umbreld: Umbreld
@@ -210,13 +210,13 @@ export default class Apps {
210210
this.logger.log(`Installing app ${appId}`)
211211
const appTemplatePath = await this.#umbreld.appStore.getAppTemplateFilePath(appId)
212212

213-
let manifestVersion: AppManifest['manifestVersion']
213+
let manifest: AppManifest
214214
try {
215-
manifestVersion = (await readManifestInDirectory(appTemplatePath)).manifestVersion
215+
manifest = await readManifestInDirectory(appTemplatePath)
216216
} catch {
217217
throw new Error('App template not found')
218218
}
219-
const manifestVersionValid = semver.valid(manifestVersion)
219+
const manifestVersionValid = semver.valid(manifest.manifestVersion)
220220
if (!manifestVersionValid) {
221221
throw new Error('App manifest version is invalid')
222222
}
@@ -235,7 +235,8 @@ export default class Apps {
235235

236236
// Save reference to app instance
237237
const app = new App(this.#umbreld, appId)
238-
app.store.set('dependencies', alternatives || {})
238+
const filledSelectedDependencies = fillSelectedDependencies(manifest.dependencies, alternatives)
239+
await app.store.set('dependencies', filledSelectedDependencies)
239240
this.instances.push(app)
240241

241242
// Complete the install process via the app script
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Ensure selected dependencies are filled in when given the app's dependencies
3+
* (where undefined = none) and a user's selection (where undefined = default).
4+
*/
5+
export const fillSelectedDependencies = (dependencies?: string[], selectedDependencies?: Record<string, string>) =>
6+
dependencies?.reduce(
7+
(accumulator, dependencyId) => {
8+
accumulator[dependencyId] = selectedDependencies?.[dependencyId] ?? dependencyId
9+
return accumulator
10+
},
11+
{} as Record<string, string>,
12+
) ?? {}

packages/umbreld/source/modules/utilities/file-store.integration.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'node:path'
22
import {describe, beforeAll, afterAll, expect, test} from 'vitest'
3+
import fse from 'fs-extra'
34

45
import temporaryDirectory from './temporary-directory.js'
56

@@ -168,3 +169,26 @@ describe('store.getWriteLock()', () => {
168169
})
169170
})
170171
})
172+
173+
const createFaultyStore = async () => {
174+
const filePath = path.join(await directory.create(), 'store.yaml')
175+
176+
// Create a faulty store where the store file is empty, in turn
177+
// deserializing as `undefined` if not explicitly handled
178+
await fse.ensureFile(filePath)
179+
180+
type LooseSchema = Record<string, any>
181+
const store = new FileStore<LooseSchema>({filePath})
182+
183+
return store
184+
}
185+
186+
describe('Filestore', () => {
187+
test('recovers from faulty store', async () => {
188+
const store = await createFaultyStore()
189+
expect(await store.get()).toStrictEqual({})
190+
191+
await store.set('test', 123)
192+
expect(await store.get('test')).toStrictEqual(123)
193+
})
194+
})

packages/umbreld/source/modules/utilities/file-store.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ type DotProp<T, P extends string> = P extends `${infer K}.${infer R}`
1717

1818
type StorePath<T, P extends string> = DotProp<T, P> extends never ? 'The provided path does not exist in the store' : P
1919

20-
export default class FileStore<T> {
20+
type Primitive = number | string | boolean | null | undefined
21+
type Serializable = {
22+
[key: string]: Serializable | Serializable[] | Primitive | Primitive[]
23+
}
24+
25+
export default class FileStore<T extends Serializable> {
2126
filePath: string
2227

2328
#parser
@@ -37,9 +42,10 @@ export default class FileStore<T> {
3742
}
3843

3944
async #read() {
40-
const rawData = await getOrCreateFile(this.filePath, this.#parser.encode({}))
45+
const defaultValue: Serializable = {}
46+
const rawData = await getOrCreateFile(this.filePath, this.#parser.encode(defaultValue))
4147

42-
const store = this.#parser.decode(rawData) as T
48+
const store = (this.#parser.decode(rawData) || defaultValue) as T
4349

4450
return store
4551
}

0 commit comments

Comments
 (0)