From 82b370c09c5cd0cc41c6e0c09915aa8687a04f3f Mon Sep 17 00:00:00 2001 From: Iku-turso Date: Tue, 16 May 2023 15:44:25 +0300 Subject: [PATCH] feat: Introduce Feature for persisted state Co-authored-by: Janne Savolainen Signed-off-by: Iku-turso --- .../persisted-state/.eslintrc.json | 6 + .../persisted-state/.prettierrc | 1 + .../utility-features/persisted-state/index.ts | 17 + .../persisted-state/jest.config.js | 1 + .../persisted-state/package.json | 45 +++ .../create-persisted-state.injectable.ts | 231 ++++++++++++ .../create-persisted-state.test.ts | 356 ++++++++++++++++++ .../persisted-state/src/feature.ts | 18 + .../persisted-state/tsconfig.json | 4 + .../persisted-state/webpack.config.js | 1 + 10 files changed, 680 insertions(+) create mode 100644 packages/utility-features/persisted-state/.eslintrc.json create mode 100644 packages/utility-features/persisted-state/.prettierrc create mode 100644 packages/utility-features/persisted-state/index.ts create mode 100644 packages/utility-features/persisted-state/jest.config.js create mode 100644 packages/utility-features/persisted-state/package.json create mode 100644 packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.injectable.ts create mode 100644 packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.test.ts create mode 100644 packages/utility-features/persisted-state/src/feature.ts create mode 100644 packages/utility-features/persisted-state/tsconfig.json create mode 100644 packages/utility-features/persisted-state/webpack.config.js diff --git a/packages/utility-features/persisted-state/.eslintrc.json b/packages/utility-features/persisted-state/.eslintrc.json new file mode 100644 index 0000000000..b15115cb69 --- /dev/null +++ b/packages/utility-features/persisted-state/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "@k8slens/eslint-config/eslint", + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/utility-features/persisted-state/.prettierrc b/packages/utility-features/persisted-state/.prettierrc new file mode 100644 index 0000000000..edd47b479e --- /dev/null +++ b/packages/utility-features/persisted-state/.prettierrc @@ -0,0 +1 @@ +"@k8slens/eslint-config/prettier" diff --git a/packages/utility-features/persisted-state/index.ts b/packages/utility-features/persisted-state/index.ts new file mode 100644 index 0000000000..603c9cfa01 --- /dev/null +++ b/packages/utility-features/persisted-state/index.ts @@ -0,0 +1,17 @@ +import { feature } from "./src/feature"; + +export { + createPersistedStateInjectionToken, + persistedStateInjectionToken, +} from "./src/create-persisted-state/create-persisted-state.injectable"; + +export type { + CreatePersistedState, + CreatePersistedStateConfig, + PersistedState, + PersistedStateResult, + NonPendingPersistedStateResult, + PendingPersistedStateResult, +} from "./src/create-persisted-state/create-persisted-state.injectable"; + +export default feature; diff --git a/packages/utility-features/persisted-state/jest.config.js b/packages/utility-features/persisted-state/jest.config.js new file mode 100644 index 0000000000..c6074967eb --- /dev/null +++ b/packages/utility-features/persisted-state/jest.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode; diff --git a/packages/utility-features/persisted-state/package.json b/packages/utility-features/persisted-state/package.json new file mode 100644 index 0000000000..91ffae3adb --- /dev/null +++ b/packages/utility-features/persisted-state/package.json @@ -0,0 +1,45 @@ +{ + "name": "@k8slens/persisted-state", + "private": false, + "version": "0.1.0", + "description": "TBD", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "type": "commonjs", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "build": "lens-webpack-build", + "clean": "rimraf dist/", + "test:unit": "jest --coverage --runInBand", + "lint": "lens-lint", + "lint:fix": "lens-lint --fix" + }, + "peerDependencies": { + "@k8slens/app-paths": "^0.1.0", + "@k8slens/file-system": "^0.1.0", + "@k8slens/logger": "1.0.0-alpha.6", + "@ogre-tools/fp": "^16.1.0", + "@ogre-tools/injectable": "^16.1.0", + "@ogre-tools/injectable-extension-for-auto-registration": "^16.1.0", + "@ogre-tools/injectable-extension-for-mobx": "^16.1.0", + "lodash": "^4.17.15", + "mobx": "^6.7.0" + }, + "devDependencies": { + "@k8slens/eslint-config": "^6.5.0-alpha.3", + "@k8slens/react-testing-library-discovery": "^1.0.0-alpha.4", + "@k8slens/webpack": "^6.5.0-alpha.6" + } +} diff --git a/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.injectable.ts b/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.injectable.ts new file mode 100644 index 0000000000..9af60ba4c4 --- /dev/null +++ b/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.injectable.ts @@ -0,0 +1,231 @@ +import { + getInjectable, + getInjectionToken, + lifecycleEnum, +} from "@ogre-tools/injectable"; +import type { AnySchema } from "ajv"; + +import { computed, IComputedValue, observable, runInAction } from "mobx"; +import type { JsonValue } from "type-fest"; +import { + readJsonFileInjectionToken, + writeJsonFileInjectionToken, +} from "@lensapp/fs"; +import { validateJsonSchema, withThrownFailuresUnless } from "@lensapp/utils"; +import { constant } from "lodash/fp"; +import { logErrorInjectionToken } from "@lensapp/logging"; +import { + appPathsInjectionToken, + joinPathsInjectionToken, +} from "@lensapp/app-paths"; + +export type CreatePersistedStateConfig = { + id: string; + schema: AnySchema; + defaultValue: T; +}; + +export type NonPendingPersistedStateResult = { + pending: false; + value: T; +}; + +export type PendingPersistedStateResult = { + pending: true; +}; + +export type PersistedStateResult = + | NonPendingPersistedStateResult + | PendingPersistedStateResult; + +export const persistedStateInjectionToken = getInjectionToken< + PersistedState +>({ + id: "persisted-state-injection-token", +}); + +export const createPersistedStateInjectionToken = + getInjectionToken({ + id: "create-persisted-state-injection-token", + }); + +export interface PersistedState { + result: IComputedValue>; + getAsyncValue: () => Promise; + set: (newValue: T) => void; +} + +export type CreatePersistedState = ( + config: CreatePersistedStateConfig +) => PersistedState; + +export const stateBoxInjectable = getInjectable({ + id: "persisted-state-box", + + instantiate: () => observable.box(), + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, id: string) => id, + }), +}); + +export const createPersistedStateInjectable = getInjectable({ + id: "create-persisted-state", + + instantiate: (di) => { + const readJsonFileWithThrownFailures = di.inject( + readJsonFileInjectionToken + ); + + const readJsonFile = withThrownFailuresUnless( + errorIsAboutMissingFile, + errorIsAboutAnythingElse + )(readJsonFileWithThrownFailures); + + const writeJsonFile = withThrownFailuresUnless(constant(true))( + di.inject(writeJsonFileInjectionToken) + ); + + const logError = di.inject(logErrorInjectionToken); + const appPaths = di.inject(appPathsInjectionToken); + const joinPaths = di.inject(joinPathsInjectionToken); + + const alreadyCreated = new Set(); + + return ({ + id: persistedStateId, + defaultValue, + schema, + }: CreatePersistedStateConfig) => { + if (alreadyCreated.has(persistedStateId)) { + throw new Error( + `Tried to create persisted state for "${persistedStateId}", but it was already created` + ); + } + + const validateSchema = validateJsonSchema(getFileSchema(schema)); + + alreadyCreated.add(persistedStateId); + + const stateJsonPath = joinPaths( + appPaths.userData, + `persisted-states/${persistedStateId}.json` + ); + + const valueBox = observable.box(defaultValue); + const pendingBox = observable.box(true); + + let stallAsyncValue = readJsonFile(stateJsonPath).then( + (stateValueCall) => { + if (!stateValueCall.callWasSuccessful) { + if (!errorIsAboutMissingFile(stateValueCall.error.cause)) { + logError( + `Tried to read persisted states from "${stateJsonPath}" but it failed with:\n\n${stateValueCall.error.message}` + ); + } + + runInAction(() => { + pendingBox.set(false); + }); + + return; + } + + const response = stateValueCall.response; + + const validated = validateSchema(response); + + if (validated.valid) { + runInAction(() => { + valueBox.set(response.value); + pendingBox.set(false); + }); + } + } + ); + + return { + result: computed(() => + pendingBox.get() + ? { pending: true as const } + : { + pending: false as const, + value: valueBox.get(), + } + ), + + set: async (newValue: T) => { + if (pendingBox.get()) { + throw new Error( + `Tried to set a persisted state for "${persistedStateId}", but call for the existing persisted state hadn't finished yet` + ); + } + + const latestGoodValue = valueBox.get(); + + const validateSchema = validateJsonSchema(schema); + + const validated = validateSchema(newValue); + + if (!validated.valid) { + throw new Error( + `Tried to set value of persisted state "${persistedStateId}" but validation of new value failed with:\n\n${JSON.stringify( + validated.validationErrors, + null, + 2 + )}` + ); + } + + runInAction(() => { + valueBox.set(newValue); + }); + + const resultPromise = writeJsonFile(stateJsonPath, { + version: 1, + value: newValue, + }); + + stallAsyncValue = resultPromise as Promise; + + const result = await resultPromise; + + if (!result.callWasSuccessful) { + runInAction(() => { + valueBox.set(latestGoodValue); + }); + + logError( + `Tried to persist state to "${stateJsonPath}" but attempt failed with:\n\n${result.error.message}` + ); + } + }, + + getAsyncValue: async () => { + await stallAsyncValue; + + return valueBox.get(); + }, + }; + }; + }, + + injectionToken: createPersistedStateInjectionToken, +}); + +const getFileSchema = (valueSchema: AnySchema) => ({ + type: "object", + + properties: { + version: { type: "number" }, + value: valueSchema, + }, + + required: ["version", "value"], + additionalProperties: false, +}); + +const errorIsAboutMissingFile = (error: unknown) => + (error as any).code === "ENOENT"; + +const errorIsAboutAnythingElse = () => true; diff --git a/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.test.ts b/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.test.ts new file mode 100644 index 0000000000..81a04c23d1 --- /dev/null +++ b/packages/utility-features/persisted-state/src/create-persisted-state/create-persisted-state.test.ts @@ -0,0 +1,356 @@ +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import { + createContainer, + DiContainer, + getInjectable, +} from "@ogre-tools/injectable"; +import { reaction, runInAction } from "mobx"; +import { + CreatePersistedState, + createPersistedStateInjectionToken, + PersistedState, + PersistedStateResult, +} from "./create-persisted-state.injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { + ReadJsonFile, + readJsonFileInjectionToken, + WriteJsonFile, + writeJsonFileInjectionToken, +} from "@lensapp/fs"; +import { getSuccess } from "@lensapp/utils"; +import { logErrorInjectionToken } from "@lensapp/logging"; +import { appPathsInjectionToken } from "@lensapp/app-paths"; +import { getPromiseStatus, useFakeTime } from "@lensapp/test-utils"; +import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx"; +import { feature } from "../feature"; + +describe("create persisted state", () => { + let di: DiContainer; + let readJsonFileMock: AsyncFnMock; + let writeJsonFileMock: AsyncFnMock; + let logErrorMock: jest.Mock; + let createPersistedState: CreatePersistedState; + + beforeEach(() => { + useFakeTime(); + + di = createContainer("irrelevant"); + + registerFeature(di, feature); + + di.register(appPathsFakeInjectable); + + registerMobX(di); + + readJsonFileMock = asyncFn(); + di.override(readJsonFileInjectionToken, () => readJsonFileMock); + + writeJsonFileMock = asyncFn(); + di.override(writeJsonFileInjectionToken, () => writeJsonFileMock); + + logErrorMock = jest.fn(); + di.override(logErrorInjectionToken, () => logErrorMock); + + createPersistedState = di.inject(createPersistedStateInjectionToken); + }); + + describe("when a persisted state is created", () => { + let actualPersistedState: PersistedState; + + beforeEach(() => { + actualPersistedState = createPersistedState({ + id: "some-persisted-state-id", + defaultValue: "some-default-value", + + schema: { + oneOf: [ + { + enum: ["some-existing-value", "some-changed-value"], + }, + + { + type: "null", + }, + ], + }, + }); + }); + + it("reads persisted value", () => { + expect(readJsonFileMock).toHaveBeenCalledWith( + "/some-user-data-directory/persisted-states/some-persisted-state-id.json" + ); + }); + + it("when persisted state with conflicting ID is created, throws", () => { + expect(() => { + createPersistedState({ + id: "some-persisted-state-id", + defaultValue: "irrelevant", + schema: {}, + }); + }).toThrow( + 'Tried to create persisted state for "some-persisted-state-id", but it was already created' + ); + }); + + describe("when value is accessed as promise instead of observing", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = actualPersistedState.getAsyncValue(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when existing value resolves", () => { + beforeEach(async () => { + await readJsonFileMock.resolve( + getSuccess({ + version: 1, + value: "some-existing-value", + }) + ); + }); + + it("resolves as the existing value", async () => { + expect(await actualPromise).toBe("some-existing-value"); + }); + }); + }); + + describe("when value is accessed as observation instead of promise", () => { + let observedResult: PersistedStateResult; + let observedValue: string | undefined; + + beforeEach(() => { + reaction( + () => actualPersistedState.result.get(), + + (newValue) => { + observedResult = newValue; + observedValue = newValue.pending ? undefined : newValue.value; + }, + + { fireImmediately: true } + ); + }); + + it("state observes as pending", () => { + expect(observedResult.pending).toBe(true); + }); + + it("when new value is set before existing value has resolved, throws", () => { + return expect(actualPersistedState.set("irrelevant")).rejects.toThrow( + 'Tried to set a persisted state for "some-persisted-state-id", but call for the existing persisted state hadn\'t finished yet' + ); + }); + + describe("when existing value resolves", () => { + beforeEach(async () => { + writeJsonFileMock.mockClear(); + + await readJsonFileMock.resolve( + getSuccess({ + version: 1, + value: "some-existing-value", + }) + ); + }); + + describe("when value is accessed as promise instead of observing", () => { + let actualPromise: Promise; + + beforeEach(() => { + readJsonFileMock.mockClear(); + actualPromise = actualPersistedState.getAsyncValue(); + }); + + it("resolves as the existing value", async () => { + expect(await actualPromise).toBe("some-existing-value"); + }); + + it("does not call for existing values again", () => { + expect(readJsonFileMock).not.toHaveBeenCalled(); + }); + }); + + it("state observes as the existing value", () => { + expect(observedValue).toBe("some-existing-value"); + }); + + it("does not persist the value again", () => { + expect(writeJsonFileMock).not.toHaveBeenCalled(); + }); + + describe("when the state is changed to a valid value", () => { + beforeEach(() => { + runInAction(async () => { + await actualPersistedState.set("some-changed-value"); + }); + }); + + describe("when value is accessed as promise instead of observing", () => { + let actualPromise: Promise; + + beforeEach(() => { + readJsonFileMock.mockClear(); + actualPromise = actualPersistedState.getAsyncValue(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("does not call for existing values again", () => { + expect(readJsonFileMock).not.toHaveBeenCalled(); + }); + + describe("when persisting resolves", () => { + beforeEach(async () => { + await writeJsonFileMock.resolve(getSuccess(undefined)); + }); + + it("resolves as the changed value", async () => { + expect(await actualPromise).toBe("some-changed-value"); + }); + }); + }); + + it("persists the value", () => { + expect(writeJsonFileMock).toHaveBeenCalledWith( + "/some-user-data-directory/persisted-states/some-persisted-state-id.json", + { version: 1, value: "some-changed-value" } + ); + }); + + it("state observes as the changed value", () => { + expect(observedValue).toBe("some-changed-value"); + }); + + describe("when persisting resolves", () => { + beforeEach(async () => { + await writeJsonFileMock.resolve(getSuccess(undefined)); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("observed value is still the changed value", () => { + expect(observedValue).toBe("some-changed-value"); + }); + }); + + describe("when persisting rejects", () => { + beforeEach(async () => { + await writeJsonFileMock.reject(new Error("some-error")); + }); + + it("logs error", () => { + expect(logErrorMock).toHaveBeenCalledWith( + 'Tried to persist state to "/some-user-data-directory/persisted-states/some-persisted-state-id.json" but attempt failed with:\n\nsome-error' + ); + }); + + it("observed value is the latest good value", () => { + expect(observedValue).toBe("some-existing-value"); + }); + }); + }); + + describe("when the state is changed to an invalid value", () => { + let error: any; + + beforeEach(() => { + runInAction(async () => { + try { + await actualPersistedState.set("some-invalid-value"); + } catch (e) { + error = e; + } + }); + }); + + it("does not persist the value", () => { + expect(writeJsonFileMock).not.toHaveBeenCalled(); + }); + + it("state observes as latest good value", () => { + expect(observedValue).toBe("some-existing-value"); + }); + + it("throws", () => { + expect(error.message).toEqual( + expect.stringContaining( + 'Tried to set value of persisted state "some-persisted-state-id" but validation of new value failed with:' + ) + ); + }); + }); + }); + + describe("when reading resolves with invalid content", () => { + beforeEach(async () => { + await readJsonFileMock.resolve(getSuccess({ version: 1, value: 42 })); + }); + + it("observes as pending (eternally)", () => { + expect(observedResult.pending).toBe(true); + }); + }); + + describe("when reading rejects with error for non existing file", () => { + beforeEach(async () => { + const errorAboutMissingFile = new Error("irrelevant"); + + (errorAboutMissingFile as any).code = "ENOENT"; + + await readJsonFileMock.reject(errorAboutMissingFile); + }); + + it("does not log error", () => { + expect(logErrorMock).not.toHaveBeenCalled(); + }); + + it("state observes as default value", () => { + expect(observedValue).toBe("some-default-value"); + }); + }); + + describe("when reading rejects with any other error than for non existing file", () => { + beforeEach(async () => { + const anyOtherError = new Error("some error"); + + await readJsonFileMock.reject(anyOtherError); + }); + + it("logs error", () => { + expect(logErrorMock).toHaveBeenCalledWith( + 'Tried to read persisted states from "/some-user-data-directory/persisted-states/some-persisted-state-id.json" but it failed with:\n\nsome error' + ); + }); + + it("state observes as default value", () => { + expect(observedValue).toBe("some-default-value"); + }); + }); + }); + }); +}); + +const appPathsFakeInjectable = getInjectable({ + id: "app-paths-fake", + instantiate: () => + ({ + userData: "/some-user-data-directory", + } as any), + injectionToken: appPathsInjectionToken, +}); diff --git a/packages/utility-features/persisted-state/src/feature.ts b/packages/utility-features/persisted-state/src/feature.ts new file mode 100644 index 0000000000..a339bc2245 --- /dev/null +++ b/packages/utility-features/persisted-state/src/feature.ts @@ -0,0 +1,18 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { getFeature } from "@k8slens/feature-core"; +import fsFeature from "@k8slens/file-system"; +import loggingFeature from "@k8slens/logger"; +import appPathsFeature from "@k8slens/app-paths"; + +export const feature = getFeature({ + id: "persisted-state", + dependencies: [fsFeature, loggingFeature, appPathsFeature], + + register: (di) => { + autoRegister({ + di, + targetModule: module, + getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)], + }); + }, +}); diff --git a/packages/utility-features/persisted-state/tsconfig.json b/packages/utility-features/persisted-state/tsconfig.json new file mode 100644 index 0000000000..1819203dc1 --- /dev/null +++ b/packages/utility-features/persisted-state/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@k8slens/typescript/config/base.json", + "include": ["**/*.ts"] +} diff --git a/packages/utility-features/persisted-state/webpack.config.js b/packages/utility-features/persisted-state/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/utility-features/persisted-state/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode;