1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

feat: Introduce Feature for persisted state

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>
Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>
This commit is contained in:
Iku-turso 2023-05-16 15:44:25 +03:00
parent d56bd1bd69
commit 82b370c09c
10 changed files with 680 additions and 0 deletions

View File

@ -0,0 +1,6 @@
{
"extends": "@k8slens/eslint-config/eslint",
"parserOptions": {
"project": "./tsconfig.json"
}
}

View File

@ -0,0 +1 @@
"@k8slens/eslint-config/prettier"

View File

@ -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;

View File

@ -0,0 +1 @@
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;

View File

@ -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"
}
}

View File

@ -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<T extends JsonValue> = {
id: string;
schema: AnySchema;
defaultValue: T;
};
export type NonPendingPersistedStateResult<T extends JsonValue> = {
pending: false;
value: T;
};
export type PendingPersistedStateResult = {
pending: true;
};
export type PersistedStateResult<T extends JsonValue> =
| NonPendingPersistedStateResult<T>
| PendingPersistedStateResult;
export const persistedStateInjectionToken = getInjectionToken<
PersistedState<any>
>({
id: "persisted-state-injection-token",
});
export const createPersistedStateInjectionToken =
getInjectionToken<CreatePersistedState>({
id: "create-persisted-state-injection-token",
});
export interface PersistedState<T extends JsonValue> {
result: IComputedValue<PersistedStateResult<T>>;
getAsyncValue: () => Promise<T>;
set: (newValue: T) => void;
}
export type CreatePersistedState = <T extends JsonValue>(
config: CreatePersistedStateConfig<T>
) => PersistedState<T>;
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<string>();
return <T extends JsonValue>({
id: persistedStateId,
defaultValue,
schema,
}: CreatePersistedStateConfig<T>) => {
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<any>;
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;

View File

@ -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<ReadJsonFile>;
let writeJsonFileMock: AsyncFnMock<WriteJsonFile>;
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<string>;
beforeEach(() => {
actualPersistedState = createPersistedState<string>({
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<string>;
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<string>;
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<string>;
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<string>;
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,
});

View File

@ -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)$/)],
});
},
});

View File

@ -0,0 +1,4 @@
{
"extends": "@k8slens/typescript/config/base.json",
"include": ["**/*.ts"]
}

View File

@ -0,0 +1 @@
module.exports = require("@k8slens/webpack").configForNode;