Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(libraries): type safe local storage json #6455

Merged
merged 13 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-lies-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hive': patch
---

A minor defect in Laboratory has been fixed that previously caused the application to crash when local storage was in a particular state.
11 changes: 6 additions & 5 deletions packages/web/app/src/components/target/explorer/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
useState,
} from 'react';
import { startOfDay } from 'date-fns';
import { resolveRange, type Period } from '@/lib/date-math';
import { z } from 'zod';
import { Period, resolveRange } from '@/lib/date-math';
import { subDays } from '@/lib/date-time';
import { useLocalStorageJson } from '@/lib/hooks';
import { UTCDate } from '@date-fns/utc';
Expand All @@ -27,7 +28,7 @@ type SchemaExplorerContextType = {
refreshResolvedPeriod(): void;
};

const defaultPeriod = {
const defaultPeriod: Period = {
from: 'now-7d',
to: 'now',
};
Expand Down Expand Up @@ -56,11 +57,11 @@ export function SchemaExplorerProvider({ children }: { children: ReactNode }): R

const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorageJson(
'hive:schema-explorer:collapsed',
true,
z.boolean().default(true),
);
const [period, setPeriod] = useLocalStorageJson<Period>(
const [period, setPeriod] = useLocalStorageJson(
'hive:schema-explorer:period-1',
defaultPeriod,
Period.default(defaultPeriod),
);
const [resolvedPeriod, setResolvedPeriod] = useState<Period>(() => resolveRange(period));

Expand Down
11 changes: 9 additions & 2 deletions packages/web/app/src/components/ui/changelog/changelog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReactElement, useCallback, useEffect } from 'react';
import { format } from 'date-fns/format';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useLocalStorageJson, useToggle } from '@/lib/hooks';
Expand All @@ -19,8 +20,14 @@ export function Changelog(props: { changes: Changelog[] }): ReactElement {

function ChangelogPopover(props: { changes: Changelog[] }) {
const [isOpen, toggle] = useToggle();
const [displayDot, setDisplayDot] = useLocalStorageJson<boolean>('hive:changelog:dot', false);
const [readChanges, setReadChanges] = useLocalStorageJson<string[]>('hive:changelog:read', []);
const [displayDot, setDisplayDot] = useLocalStorageJson(
'hive:changelog:dot',
z.boolean().default(false),
);
const [readChanges, setReadChanges] = useLocalStorageJson(
'hive:changelog:read',
z.array(z.string()).default([]),
);
const hasNewChanges = props.changes.some(change => !readChanges.includes(change.href));

useEffect(() => {
Expand Down
10 changes: 6 additions & 4 deletions packages/web/app/src/lib/date-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
* @source https://github.com/grafana/grafana/blob/411c89012febe13323e4b8aafc8d692f4460e680/packages/grafana-data/src/datetime/datemath.ts#L1C1-L208C2
*/
import { add, format, formatISO, parse as parseDate, sub, type Duration } from 'date-fns';
import { z } from 'zod';
import { UTCDate } from '@date-fns/utc';

export type Period = {
from: string;
to: string;
};
export const Period = z.object({
from: z.string(),
to: z.string(),
});
export type Period = z.infer<typeof Period>;

export type DurationUnit = 'y' | 'M' | 'w' | 'd' | 'h' | 'm';
export const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm'];
Expand Down
68 changes: 60 additions & 8 deletions packages/web/app/src/lib/hooks/use-local-storage-json.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,75 @@
import { useCallback, useState } from 'react';
import { z } from 'zod';
import { Kit } from '../kit';

export function useLocalStorageJson<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(() => {
const json = localStorage.getItem(key);
export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInput<$Schema>) {
const [key, schema, manualDefaultValue] = args as any as Args<$Schema>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unsafe type casting.

The use of as any bypasses TypeScript's type checking. Consider using type predicates or type guards instead.

-  const [key, schema, manualDefaultValue] = args as any as Args<$Schema>;
+  const [key, schema, manualDefaultValue] = args as Args<$Schema>;

Committable suggestion skipped: line range outside the PR's diff.

// The parameter types will force the user to give a manual default
// if their given Zod schema does not have default.
//
// We resolve that here because in the event of a Zod parse failure, we fallback
// to the default value, meaning we are needing a reference to the Zod default outside
// of the regular parse process.
//
const defaultValue =
manualDefaultValue !== undefined
? manualDefaultValue
: Kit.ZodHelpers.isDefaultType(schema)
? (schema._def.defaultValue() as z.infer<$Schema>)
: Kit.never();

const [value, setValue] = useState<z.infer<$Schema>>(() => {
// Note: `null` is returned for missing values. However Zod only kicks in
// default values for `undefined`, not `null`. However-however, this is ok,
// because we manually pre-compute+return the default value, thus we don't
// rely on Zod's behaviour. If that changes this should have `?? undefined`
// added.
const storedValue = localStorage.getItem(key);

if (!storedValue) {
return defaultValue;
}

// todo: Some possible improvements:
// - Monitor json/schema parse failures.
// - Let caller choose an error strategy: 'return' / 'default' / 'throw'
try {
const result = json ? JSON.parse(json) : defaultValue;
return result;
} catch (_) {
return schema.parse(JSON.parse(storedValue));
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(`useLocalStorageJson: JSON parsing failed for key "${key}"`, error);
} else if (error instanceof z.ZodError) {
console.warn(`useLocalStorageJson: Schema validation failed for key "${key}"`, error);
} else {
Kit.neverCatch(error);
}
return defaultValue;
}
});

const set = useCallback(
(value: T) => {
(value: z.infer<$Schema>) => {
localStorage.setItem(key, JSON.stringify(value));
setValue(value);
},
[setValue],
[key],
);

return [value, set] as const;
}

type ArgsInput<$Schema extends z.ZodType> =
$Schema extends z.ZodDefault<z.ZodType>
? [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>]
: [key: string, schema: ArgsInputGuardZodJsonSchema<$Schema>, defaultValue: z.infer<$Schema>];

type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> =
z.infer<$Schema> extends Kit.Json.Value
? $Schema
: 'Error: Your Zod schema is or contains a type that is not valid JSON.';

type Args<$Schema extends z.ZodType> = [
key: string,
schema: $Schema,
defaultValue?: z.infer<$Schema>,
];
18 changes: 13 additions & 5 deletions packages/web/app/src/lib/kit/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
// eslint-disable-next-line import/no-self-import
export * as Kit from './index';
// Storybook (or the version we are using)
// is using a version of Babel that does not
// support re-exporting as namespaces:
//
// export * as Kit from './index';
//
// So we have to re-export everything manually
// and incur an additional index_ file for it
// too:

export * from './never';
export * from './types/headers';
export * from './helpers';
import * as Kit from './index_';

// eslint-disable-next-line unicorn/prefer-export-from
export { Kit };
5 changes: 5 additions & 0 deletions packages/web/app/src/lib/kit/index_.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './never';
export * from './types/headers';
export * from './helpers';
export * from './json';
export * from './zod-helpers';
19 changes: 19 additions & 0 deletions packages/web/app/src/lib/kit/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from 'zod';
import { ZodHelpers } from './zod-helpers';

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Json {
export const Primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]);
export type Primitive = z.infer<typeof Primitive>;
export const isPrimitive = ZodHelpers.createTypeGuard(Primitive);

export const Value: z.ZodType<Value> = z.lazy(() =>
z.union([Primitive, z.array(Value), z.record(Value)]),
);
export type Value = Primitive | { [key: string]: Value } | Value[];
export const isValue = ZodHelpers.createTypeGuard(Value);

export const Object: z.ZodType<Object> = z.record(Value);
export type Object = { [key: string]: Value };
export const isObject = ZodHelpers.createTypeGuard(Object);
jasonkuhrt marked this conversation as resolved.
Show resolved Hide resolved
}
12 changes: 10 additions & 2 deletions packages/web/app/src/lib/kit/never.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
/**
* This case of thrown value is impossible.
* If it happens, then that means there is a defect in our code.
*/
export const neverCatch = (value: unknown): never => {
never({ type: 'catch', value });
};

/**
* This case is impossible.
* If it happens, then that means there is a bug in our code.
* If it happens, then that means there is a defect in our code.
*/
export const neverCase = (value: never): never => {
never({ type: 'case', value });
};

/**
* This code cannot be reached.
* If it is reached, then that means there is a bug in our code.
* If it is reached, then that means there is a defect in our code.
*/
export const never: (context?: object) => never = context => {
throw new Error('Something that should be impossible happened', { cause: context });
Expand Down
15 changes: 15 additions & 0 deletions packages/web/app/src/lib/kit/zod-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod';

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ZodHelpers {
export const isDefaultType = (zodType: z.ZodType): zodType is z.ZodDefault<z.ZodType> => {
return 'defaultValue' in zodType._def;
};

export const createTypeGuard =
<$Schema extends z.ZodType, $Value = z.infer<$Schema>>(schema: $Schema) =>
(value: unknown): value is $Value => {
const result = schema.safeParse(value);
return result.success;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { clsx } from 'clsx';
import { PowerIcon } from 'lucide-react';
import type { editor } from 'monaco-editor';
import { useMutation } from 'urql';
import { z } from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -155,7 +156,7 @@ export function usePreflightScript(args: {
const target = useFragment(PreflightScript_TargetFragment, args.target);
const [isPreflightScriptEnabled, setIsPreflightScriptEnabled] = useLocalStorageJson(
'hive:laboratory:isPreflightScriptEnabled',
false,
z.boolean().default(false),
);
const [environmentVariables, setEnvironmentVariables] = useLocalStorage(
'hive:laboratory:environment',
Expand Down
29 changes: 0 additions & 29 deletions packages/web/app/src/lib/preflight-sandbox/json.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import CryptoJS from 'crypto-js';
import CryptoJSPackageJson from 'crypto-js/package.json';
import { Kit } from '../kit';
import { ALLOWED_GLOBALS } from './allowed-globals';
import { isJSONPrimitive, JSONPrimitive } from './json';
import { LogMessage, WorkerEvents } from './shared-types';

interface WorkerData {
request: {
headers: Headers;
};
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}

/**
Expand Down Expand Up @@ -108,7 +108,7 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise<void> {
};

function getValidEnvVariable(value: unknown) {
if (isJSONPrimitive(value)) {
if (Kit.Json.isPrimitive(value)) {
return value;
}
consoleApi.warn(
Expand Down
9 changes: 4 additions & 5 deletions packages/web/app/src/lib/preflight-sandbox/shared-types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-namespace */

import { Kit } from '../kit';
import { JSONPrimitive } from './json';

type _MessageEvent<T> = MessageEvent<T>;

Expand Down Expand Up @@ -48,7 +47,7 @@ export namespace IFrameEvents {
export interface Result {
type: Event.result;
runId: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
request: {
headers: Kit.Headers.Encoded;
};
Expand Down Expand Up @@ -93,7 +92,7 @@ export namespace IFrameEvents {
type: Event.run;
id: string;
script: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}

export interface Abort {
Expand Down Expand Up @@ -149,7 +148,7 @@ export namespace WorkerEvents {

export interface Result {
type: Event.result;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
request: {
headers: Kit.Headers.Encoded;
};
Expand Down Expand Up @@ -187,7 +186,7 @@ export namespace WorkerEvents {
export interface Run {
type: Event.run;
script: string;
environmentVariables: Record<string, JSONPrimitive>;
environmentVariables: Record<string, Kit.Json.Primitive>;
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/web/app/src/pages/target-laboratory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import 'graphiql/style.css';
import '@graphiql/plugin-explorer/style.css';
import { PromptManager, PromptProvider } from '@/components/ui/prompt';
import { useRedirect } from '@/lib/access/common';
import { JSONPrimitive } from '@/lib/preflight-sandbox/json';
import { Kit } from '@/lib/kit';

const explorer = explorerPlugin();

Expand Down Expand Up @@ -259,7 +259,7 @@ function Save(props: {

function substituteVariablesInHeaders(
headers: Record<string, string>,
environmentVariables: Record<string, JSONPrimitive>,
environmentVariables: Record<string, Kit.Json.Primitive>,
) {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => {
Expand Down
1 change: 1 addition & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading