-
Notifications
You must be signed in to change notification settings - Fork 132
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Application State Improvements (#3166)
- Loading branch information
Showing
17 changed files
with
384 additions
and
254 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import path from "path" | ||
import fs from "fs" | ||
import {AppStateFile} from "./app-state-file" | ||
|
||
export class AppStateBackup { | ||
constructor(public dir: string) { | ||
if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir) | ||
} | ||
|
||
save(file: AppStateFile) { | ||
const backupPath = this.getPath(file.version) | ||
fs.copyFileSync(file.path, backupPath) | ||
} | ||
|
||
join(name: string) { | ||
return path.join(this.dir, name) | ||
} | ||
|
||
getPath(version: number) { | ||
const existing = fs.readdirSync(this.dir) | ||
let i = 1 | ||
let name = "" | ||
do { | ||
name = this.getName(version, i++) | ||
} while (existing.includes(name)) | ||
|
||
return this.join(name) | ||
} | ||
|
||
getName(version: number, n: number) { | ||
if (n > 1) return `${version}_backup_${n}.json` | ||
else return `${version}_backup.json` | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import fs from "fs-extra" | ||
import path from "path" | ||
import {isNumber} from "lodash" | ||
|
||
export class AppStateFile { | ||
state: any = undefined | ||
|
||
constructor(public path: string) { | ||
if (this.noFile) return | ||
if (this.noContent) return | ||
this.state = this.parse() | ||
if (this.noJSON) throw new Error(JSON_ERROR_MSG(this.path)) | ||
if (this.noVersion) throw new Error(VERSION_ERROR_MSG(this.path)) | ||
} | ||
|
||
create(version: number) { | ||
this.write({version, data: undefined}) | ||
} | ||
|
||
write(state) { | ||
fs.ensureDirSync(path.dirname(this.path)) | ||
fs.writeFileSync(this.path, JSON.stringify(state)) | ||
this.state = state | ||
} | ||
|
||
update(data) { | ||
this.write({version: this.version, data}) | ||
} | ||
|
||
destroy() { | ||
if (fs.existsSync(this.path)) fs.rmSync(this.path) | ||
} | ||
|
||
get isEmpty() { | ||
return !this.state | ||
} | ||
|
||
get name() { | ||
return path.basename(this.path) | ||
} | ||
|
||
get version() { | ||
return this.state.version | ||
} | ||
|
||
get data() { | ||
return this.state.data | ||
} | ||
|
||
private get noFile() { | ||
return !fs.existsSync(this.path) | ||
} | ||
|
||
private get noContent() { | ||
return fs.statSync(this.path).size === 0 | ||
} | ||
|
||
private get noJSON() { | ||
return !this.state | ||
} | ||
|
||
private get noVersion() { | ||
return !(typeof this.state === "object" && isNumber(this.state.version)) | ||
} | ||
|
||
private parse() { | ||
try { | ||
return JSON.parse(fs.readFileSync(this.path, "utf8")) | ||
} catch { | ||
return null | ||
} | ||
} | ||
} | ||
|
||
const JSON_ERROR_MSG = (path) => | ||
"The application state file could not be parsed as JSON:\npath: " + path | ||
|
||
const VERSION_ERROR_MSG = (path) => | ||
"The application state file is a JSON object but is missing the top-level version key of type number\npath: " + | ||
path |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
/** | ||
* @jest-environment jsdom | ||
*/ | ||
|
||
import fsExtra from "fs-extra" | ||
|
||
import path from "path" | ||
import os from "os" | ||
|
||
import disableLogger from "src/test/unit/helpers/disableLogger" | ||
import {Migrations} from "./migrations" | ||
import {AppState} from "./app-state" | ||
import states from "src/test/unit/states" | ||
|
||
const dir = path.join(os.tmpdir(), "session.test.ts") | ||
const file = path.join(dir, "appState.json") | ||
const backupDir = path.join(dir, "backups") | ||
|
||
disableLogger() | ||
beforeEach(() => fsExtra.ensureDir(dir)) | ||
afterEach(() => fsExtra.remove(dir)) | ||
|
||
function init() { | ||
return new AppState({path: file, backupDir}) | ||
} | ||
|
||
test("app state file that doesn't exist", () => { | ||
expect(fsExtra.existsSync(file)).toBe(false) | ||
const appState = init() | ||
expect(appState.data).toEqual(undefined) | ||
expect(appState.version).toEqual(Migrations.latestVersion) | ||
}) | ||
|
||
test("app state file that is empty", () => { | ||
fsExtra.createFileSync(file) | ||
const appState = init() | ||
expect(appState.data).toEqual(undefined) | ||
expect(appState.version).toEqual(Migrations.latestVersion) | ||
}) | ||
|
||
test("app state file that does not parse to JSON", () => { | ||
fsExtra.writeFileSync(file, "---\nthis_is_yaml: true\n---") | ||
expect(fsExtra.existsSync(file)).toBe(true) | ||
expect(() => { | ||
init() | ||
}).toThrow(/The application state file could not be parsed as JSON/) | ||
}) | ||
|
||
test("app state file that is JSON but has not version number", async () => { | ||
const v8 = { | ||
order: [], | ||
windows: {}, | ||
globalState: {investigation: [], pools: {zqd: {}}, version: "6"}, | ||
} | ||
fsExtra.writeJSONSync(file, v8) | ||
|
||
expect(() => { | ||
init() | ||
}).toThrow( | ||
/The application state file is a JSON object but is missing the top-level version key of type number/ | ||
) | ||
}) | ||
|
||
test("app state is migrated if migrations are pending", () => { | ||
const needsMigration = states.getPath("v1.18.0.json") | ||
const oldState = fsExtra.readJSONSync(needsMigration) | ||
expect(oldState.version).not.toEqual(Migrations.latestVersion) | ||
|
||
fsExtra.cpSync(needsMigration, file) | ||
const appState = init() | ||
|
||
expect(appState.version).toBe(Migrations.latestVersion) | ||
}) | ||
|
||
test("app state is backed if migration is needed", () => { | ||
const needsMigration = states.getPath("v1.18.0.json") | ||
const oldState = fsExtra.readJSONSync(needsMigration) | ||
fsExtra.cpSync(needsMigration, file) | ||
init() | ||
expect(fsExtra.existsSync(backupDir)).toBe(true) | ||
const backup = fsExtra.readdirSync(backupDir)[0] | ||
expect(backup).toMatch(/^\d{12}_backup.json$/) | ||
const backupFile = path.join(backupDir, backup) | ||
expect(fsExtra.readJSONSync(backupFile)).toEqual(oldState) | ||
}) | ||
|
||
test("app state is not backed up if no migration is needed", () => { | ||
init() | ||
expect(fsExtra.existsSync(backupDir)).toBe(true) | ||
expect(fsExtra.readdirSync(backupDir)).toEqual([]) | ||
}) | ||
|
||
test("backing up the same version twice creates distict backups", () => { | ||
fsExtra.cpSync(states.getPath("v1.18.0.json"), file) | ||
init() | ||
expect(fsExtra.readdirSync(backupDir)).toEqual(["202407221450_backup.json"]) | ||
fsExtra.cpSync(states.getPath("v1.18.0.json"), file) | ||
init() | ||
expect(fsExtra.readdirSync(backupDir)).toEqual([ | ||
"202407221450_backup.json", | ||
"202407221450_backup_2.json", | ||
]) | ||
fsExtra.cpSync(states.getPath("v1.18.0.json"), file) | ||
init() | ||
expect(fsExtra.readdirSync(backupDir)).toEqual([ | ||
"202407221450_backup.json", | ||
"202407221450_backup_2.json", | ||
"202407221450_backup_3.json", | ||
]) | ||
}) | ||
|
||
test("a migration error does not affect the state file", () => { | ||
const fixture = states.getPath("v1.18.0.json") | ||
fsExtra.cpSync(fixture, file) | ||
Migrations.all.push({ | ||
version: 9999_99_99_99_99, | ||
migrate: (bang) => bang.boom.boom, | ||
}) | ||
expect(() => { | ||
init() | ||
}).toThrow(/Cannot read properties of undefined \(reading 'boom'\)/) | ||
Migrations.all.pop() | ||
expect(fsExtra.readJSONSync(file)).toEqual(fsExtra.readJSONSync(fixture)) | ||
}) | ||
|
||
test("app state saves new data", () => { | ||
const appState = init() | ||
appState.save({hello: "test"}) | ||
|
||
expect(fsExtra.readJSONSync(file)).toEqual({ | ||
version: Migrations.latestVersion, | ||
data: {hello: "test"}, | ||
}) | ||
expect(appState.data).toEqual({hello: "test"}) | ||
expect(appState.version).toEqual(Migrations.latestVersion) | ||
}) | ||
|
||
test("app state reset", () => { | ||
const appState = init() | ||
appState.save({hello: "test"}) | ||
|
||
appState.reset() | ||
expect(fsExtra.readJSONSync(file)).toEqual({ | ||
version: Migrations.latestVersion, | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import {AppStateBackup} from "./app-state-backup" | ||
import {AppStateFile} from "./app-state-file" | ||
import {Migrations} from "./migrations" | ||
|
||
/** | ||
* The application state is saved in a json file called appState.json | ||
* It contains a single object. | ||
* { | ||
* version: number, | ||
* data: object | ||
* } | ||
* | ||
* In the code below, references to "state" mean the root object. | ||
* References to version and data mean the keys inside the root object. | ||
*/ | ||
|
||
export class AppState { | ||
file: AppStateFile | ||
|
||
constructor(args: {path: string | null; backupDir: string}) { | ||
const file = new AppStateFile(args.path) | ||
if (file.isEmpty) file.create(Migrations.latestVersion) | ||
const migrations = Migrations.init({from: file.version}) | ||
const backup = new AppStateBackup(args.backupDir) | ||
if (migrations.arePending) { | ||
backup.save(file) | ||
file.write(migrations.runPending(file.state)) | ||
} | ||
this.file = file | ||
} | ||
|
||
get data() { | ||
return this.file.data | ||
} | ||
|
||
get version() { | ||
return this.file.version | ||
} | ||
|
||
reset() { | ||
this.file.destroy() | ||
this.file.create(Migrations.latestVersion) | ||
} | ||
|
||
save(data) { | ||
this.file.update(data) | ||
} | ||
} |
Oops, something went wrong.