Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
20 changes: 20 additions & 0 deletions .changeset/rich-terms-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'hive': minor
---

Laboratory Environment Variables are now scoped to Target.

Previously, we stored environment variables as a static key in your browser's local storage. This meant that any changes to the environment variables would affect all targets' Laboratory.

Now when you use Laboratory, any changes to the environment variables will not affect the environment variables of other targets' Laboratory.

## Migration Details (TL;DR: You Won't Notice Anything!)

For an indefinite period of time we will support the following migration when you load Laboratory on any target. If this holds true:

1. Your browser's localStorage has a key for the global environment variables;
2. Your browser's localStorage does NOT have a key for scoped environment variables for the Target Laboratory being loaded;

Then we will initialize the scoped environment variables for the Target Laboratory being loaded with the global ones.

Laboratory will _never_ write to the global environment variables again, so this should give you a seamless migration to scoped environment variables for all your targets.
1 change: 1 addition & 0 deletions cypress/e2e/laboratory-preflight.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function setEditorScript(script: string) {
describe('Laboratory > Preflight Script', () => {
// https://github.com/graphql-hive/console/pull/6450
it('regression: loads even if local storage is set to {}', () => {
// todo update to have a target id
window.localStorage.setItem('hive:laboratory:environment', '{}');
cy.visit(`/${data.slug}/laboratory`);
cy.get(selectors.buttonGraphiQLPreflight).click();
Expand Down
17 changes: 11 additions & 6 deletions packages/web/app/src/lib/hooks/use-local-storage-json.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useCallback, useState } from 'react';
import { z } from 'zod';
import { Kit } from '../kit';
import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry';

export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInput<$Schema>) {
const [key, schema, manualDefaultValue] = args as any as Args<$Schema>;
const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key;

// The parameter types will force the user to give a manual default
// if their given Zod schema does not have default.
//
Expand All @@ -24,7 +27,7 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu
// 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);
const storedValue = readVersionedEntryLocalStorage({ spec: versionedEntry });

if (!storedValue) {
return defaultValue;
Expand All @@ -49,27 +52,29 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu

const set = useCallback(
(value: z.infer<$Schema>) => {
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(versionedEntry[0].key, JSON.stringify(value));
setValue(value);
},
[key],
[versionedEntry.map(({ key }) => key).join('+')],
);

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>];
? [key: KeyInput, schema: ArgsInputGuardZodJsonSchema<$Schema>]
: [key: KeyInput, 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,
key: KeyInput,
schema: $Schema,
defaultValue?: z.infer<$Schema>,
];

type KeyInput = string | VersionedEntrySpec;
9 changes: 6 additions & 3 deletions packages/web/app/src/lib/hooks/use-local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { useCallback, useState } from 'react';
import { readVersionedEntryLocalStorage, VersionedEntrySpec } from '../versioned-entry';

export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) {
const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key;

export function useLocalStorage(key: string, defaultValue: string) {
const [value, setValue] = useState<string>(() => {
const value = localStorage.getItem(key);
const value = readVersionedEntryLocalStorage({ spec: versionedEntry });
return value ?? defaultValue;
});

const set = useCallback(
(value: string) => {
localStorage.setItem(key, value);
localStorage.setItem(versionedEntry[0].key, value);
setValue(value);
},
[setValue],
Expand Down
7 changes: 5 additions & 2 deletions packages/web/app/src/lib/preflight/graphiql-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,16 @@ export function usePreflight(args: {

const target = useFragment(PreflightScript_TargetFragment, args.target);
const [isEnabled, setIsEnabled] = useLocalStorageJson(
// todo: ability to pass historical keys for seamless gradual migration to new key names.
// todo
// 'hive:laboratory:isPreflightEnabled',
'hive:laboratory:isPreflightScriptEnabled',
z.boolean().default(false),
);
const [environmentVariables, setEnvironmentVariables] = useLocalStorage(
'hive:laboratory:environment',
[
{ key: `hive/target:${target?.id ?? '__null__'}/laboratory/environment-variables` },
{ key: 'hive:laboratory:environment' },
],
'',
);
const latestEnvironmentVariablesRef = useRef(environmentVariables);
Expand Down
60 changes: 60 additions & 0 deletions packages/web/app/src/lib/versioned-entry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
createKeyValueStoreMemory,
KeyValueStoreDatabase,
PreviousEntriesPolicy,
readVersionedEntry,
VersionedEntrySpec,
} from './versioned-entry';

interface TestCase {
databaseBefore: KeyValueStoreDatabase;
databaseAfter: KeyValueStoreDatabase;
spec: VersionedEntrySpec;
value: string | null;
previousEntriesPolicy?: PreviousEntriesPolicy;
}

const a = 'a';
const b = 'b';
const c = 'c';

// prettier-ignore
test.for<TestCase>([
// Returns null if spec key is missing in db
{ spec: [{ key:a }], databaseBefore: {}, databaseAfter: {}, value: null },
{ spec: [{ key:a }], databaseBefore: {b}, databaseAfter: {b}, value: null },
// Returns value if spec key is present in db
{ spec: [{ key:a }], databaseBefore: {a}, databaseAfter: {a}, value: a },
{ spec: [{ key:a }], databaseBefore: {a,b}, databaseAfter: {a,b}, value: a },
{ spec: [{ key:a }, {key:b}], databaseBefore: {a}, databaseAfter: {a}, value: a },
//
// With previousEntriesPolicy = ignore (default)
//
// Previous spec keys are NOT removed from db
{ spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a,b}, value: a },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a,b,c}, value: a },
// Latest found spec key is returned
{ spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b,b}, value: b },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c,c}, value: c },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b,b,c}, value: b },
//
// With previousEntriesPolicy = remove
//
// Previous spec keys are removed from db
{ spec: [{ key:a }, {key:b}], databaseBefore: {a,b}, databaseAfter: {a}, value: a, previousEntriesPolicy: 'remove' },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {a,b,c}, databaseAfter: {a}, value: a, previousEntriesPolicy: 'remove' },
// Latest found spec key is returned AND removed from db if not current spec
{ spec: [{ key:a }, {key:b}], databaseBefore: {b}, databaseAfter: {a:b}, value: b, previousEntriesPolicy: 'remove' },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {c}, databaseAfter: {a:c}, value: c, previousEntriesPolicy: 'remove' },
{ spec: [{ key:a }, {key:b}, {key:c}], databaseBefore: {b,c}, databaseAfter: {a:b}, value: b, previousEntriesPolicy: 'remove' },
// Non-spec keys in db are not removed
{ spec: [{ key:a }, {key:b}], databaseBefore: {a,b,c}, databaseAfter: {a,c}, value: a, previousEntriesPolicy: 'remove' },
])(
'%j',
({ databaseBefore, databaseAfter, spec, value, previousEntriesPolicy }) => {
const readVersionedEntryMemory = readVersionedEntry(createKeyValueStoreMemory(databaseBefore));
const valueActual = readVersionedEntryMemory({spec, previousEntriesPolicy})
expect(databaseBefore).toEqual(databaseAfter)
expect(valueActual).toEqual(value)
},
);
129 changes: 129 additions & 0 deletions packages/web/app/src/lib/versioned-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// --------------------------------------------------------------------
// KeyValueStore Interface
// --------------------------------------------------------------------

export interface KeyValueStore {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}

export type KeyValueStoreDatabase = Record<string, string>;

// --------------------------------------------------------------------
// Versioned Entry Data Types
// --------------------------------------------------------------------

export type VersionedEntrySpec = readonly [EntrySpec, ...(readonly EntrySpec[])];

interface EntrySpec {
key: string;
// todo once we have use-case
// schema:
// fromPrevious:
}

// --------------------------------------------------------------------
// Versioned Entry Functions
// --------------------------------------------------------------------

/**
* Read a versioned entry from local storage.
*
* Migrations are automatically applied to bring previous entries up to date with current.
*
* 1. The latest entry value is returned.
* 2. If the latest entry to have a value is NOT the current entry, then current entry is set to the latest value.
* 3. All entries prior the current that are present are either deleted or ignored based on removalStrategy.
*
* @param options.removalStrategy - Strategy for handling previous entries (RemovalStrategy.Remove or RemovalStrategy.Ignore, defaults to Ignore)
*/
export const readVersionedEntry =
(keyValueStore: KeyValueStore) =>
(parameters: {
spec: VersionedEntrySpec;
/**
* @defaultValue 'ignore'
*/
previousEntriesPolicy?: PreviousEntriesPolicy;
}): string | null => {
type SearchResult = SearchResultHit | SearchResultMiss;

interface SearchResultHit extends SearchResultEither {
value: string;
}

interface SearchResultMiss extends SearchResultEither {
value: null;
}

interface SearchResultEither {
value: string | null;
entry: EntrySpec;
index: number;
}

// ---
const { spec, previousEntriesPolicy = PreviousEntriesPolicy.ignore } = parameters;

const searchResults: SearchResult[] = [];

for (const { entry, index } of spec.map((entry, index) => ({ entry, index }))) {
const value = keyValueStore.get(entry.key);
searchResults.push({ entry, value, index });
// Note: Once we have schemas, we should not remove here, wait until _after_ successful migration
if (index > 0 && previousEntriesPolicy === PreviousEntriesPolicy.remove) {
keyValueStore.remove(entry.key);
}
}
Comment on lines +76 to +83
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Consider deferring removal until after successful migration.

The code removes previous entries before potential schema migration, as noted in the comment. This could lead to data loss if migration fails.


🏁 Script executed:

#!/bin/bash
# Search for any schema migration implementations or tests
rg -g '!node_modules' -A 5 'schema.*migration|migration.*schema'

Length of output: 14789


Action: Defer Removal Until Post-Migration Success

The current implementation in packages/web/app/src/lib/versioned-entry.ts (lines 76–83) immediately calls keyValueStore.remove(entry.key) when previousEntriesPolicy is remove. Although there is active migration logic (e.g., in packages/migrations/src/actions/2024.07.23T09.36.00.schema-cleanup-tracker.ts), prematurely removing entries may lead to data loss if the accompanying schema migration fails.

Recommendations:

  • Remove the immediate key removal: Defer calling keyValueStore.remove(entry.key) until after a successful migration.
  • Integrate into the migration pipeline: Consider moving this removal step into a post-migration hook to ensure the operation only occurs after migration success.


const latestHit = searchResults.find(({ value }) => value !== null) as
| SearchResultHit
| undefined;

if (!latestHit) return null;

if (latestHit.index > 0) {
keyValueStore.set(spec[0].key, latestHit.value);
// Note: Once we have schemas, we will need to run the value through the migration pipeline.
}

return latestHit.value;
};

export const PreviousEntriesPolicy = {
remove: 'remove',
ignore: 'ignore',
} as const;

export type PreviousEntriesPolicy = keyof typeof PreviousEntriesPolicy;

// --------------------------------------------------------------------
// KeyValueStore Implementations
// --------------------------------------------------------------------

export const keyValueStoreLocalStorage: KeyValueStore = {
get(key) {
return localStorage.getItem(key);
},
set(key, value) {
localStorage.setItem(key, value);
},
remove(key) {
localStorage.removeItem(key);
},
};

export const readVersionedEntryLocalStorage = readVersionedEntry(keyValueStoreLocalStorage);

export const createKeyValueStoreMemory = (database: KeyValueStoreDatabase): KeyValueStore => ({
get(key) {
return database[key] ?? null;
},
set(key, value) {
database[key] = value;
},
remove(key) {
delete database[key];
},
});
Loading