diff --git a/.changeset/rich-terms-knock.md b/.changeset/rich-terms-knock.md new file mode 100644 index 0000000000..86608869cf --- /dev/null +++ b/.changeset/rich-terms-knock.md @@ -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. diff --git a/cypress.config.ts b/cypress.config.ts index 3b8523d2c7..3d20a57d9f 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; // eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency import { defineConfig } from 'cypress'; +import cypressPluginLocalStorageCommands from 'cypress-localstorage-commands/plugin'; import { initSeed } from './integration-tests/testkit/seed'; if (!process.env.RUN_AGAINST_LOCAL_SERVICES) { @@ -23,9 +24,11 @@ export default defineConfig({ video: isCI, screenshotOnRunFailure: isCI, defaultCommandTimeout: 15_000, // sometimes the app takes longer to load, especially in the CI - retries: 2, + retries: isCI ? 2 : 0, e2e: { - setupNodeEvents(on) { + setupNodeEvents(on, config) { + cypressPluginLocalStorageCommands(on, config); + on('task', { async seedTarget() { const owner = await seed.createOwner(); @@ -33,6 +36,11 @@ export default defineConfig({ const project = await org.createProject(); const slug = `${org.organization.slug}/${project.project.slug}/${project.target.slug}`; return { + targets: { + production: project.targets.find(_ => _.name === 'production'), + staging: project.targets.find(_ => _.name === 'staging'), + development: project.targets.find(_ => _.name === 'development'), + }, slug, refreshToken: owner.ownerRefreshToken, email: owner.ownerEmail, diff --git a/cypress/e2e/laboratory-environment-variables.cy.ts b/cypress/e2e/laboratory-environment-variables.cy.ts new file mode 100644 index 0000000000..d4a155a3a0 --- /dev/null +++ b/cypress/e2e/laboratory-environment-variables.cy.ts @@ -0,0 +1,123 @@ +import { + environmentVariablesStorageKey, + persistAuthenticationCookies, + selectors, + type Target, +} from '../support/testkit'; + +const data = { + globalEnvars: { foo: '123' }, + globalEnvarsJson: '{"foo":"123"}', + scopedEnvars: { bar: '456' }, + targetEnvarsJson: '{"bar":"456"}', +}; + +interface Ctx { + targetDevelopment: Target; + targetProduction: Target; + cookies: Cypress.Cookie[]; +} +const ctx = { + cookies: [], +} as Ctx; + +before(() => { + cy.task('seedTarget').then(({ refreshToken, targets }: any) => { + cy.setCookie('sRefreshToken', refreshToken); + ctx.targetDevelopment = targets.development; + ctx.targetProduction = targets.production; + }); +}); + +persistAuthenticationCookies(); + +const openPreflightTab = () => cy.get(selectors.buttonGraphiQLPreflight).click(); +const openPreflightModal = () => cy.dataCy(selectors.buttonModalCy).click(); + +const storageGlobalGet = () => cy.getLocalStorage(environmentVariablesStorageKey.global); +const storageGlobalSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.global, value); // prettier-ignore +const storageGlobalRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.global); + +const visitTargetDevelopment = () => cy.visit(`${ctx.targetDevelopment.path}/laboratory`); +const storageTargetDevelopmentGet = () => cy.getLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id)); // prettier-ignore +const storageTargetDevelopmentSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id), value); // prettier-ignore +const storageTargetDevelopmentRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetDevelopment.id)); // prettier-ignore + +const visitTargetProduction = () => cy.visit(`${ctx.targetProduction.path}/laboratory`); +// const storageTargetProductionGet = () => cy.getLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id)); // prettier-ignore +// const storageTargetProductionSet = (value: string) => cy.setLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id), value); // prettier-ignore +const storageTargetProductionRemove = () => cy.removeLocalStorage(environmentVariablesStorageKey.scoped(ctx.targetProduction.id)); // prettier-ignore + +beforeEach(() => { + storageGlobalRemove(); + storageTargetDevelopmentRemove(); + storageTargetProductionRemove(); +}); + +describe('tab editor', () => { + it('if state empty, is null', () => { + visitTargetDevelopment(); + openPreflightTab(); + storageTargetDevelopmentGet().should('equal', null); + storageGlobalGet().should('equal', null); + }); + + it('if storage just has target-scope value, value used', () => { + storageTargetDevelopmentSet(data.targetEnvarsJson); + visitTargetDevelopment(); + openPreflightTab(); + cy.contains(data.targetEnvarsJson); + }); + + it('if storage just has global-scope value, copied to new target-scope value, used', () => { + storageGlobalSet(data.globalEnvarsJson); + visitTargetDevelopment(); + openPreflightTab(); + cy.contains(data.globalEnvarsJson); + storageTargetDevelopmentGet().should('equal', data.globalEnvarsJson); + }); + + it('if storage has global-scope AND target-scope values, target-scope value used', () => { + storageTargetDevelopmentSet(data.targetEnvarsJson); + storageGlobalSet(data.globalEnvarsJson); + visitTargetDevelopment(); + openPreflightTab(); + cy.contains(data.targetEnvarsJson); + }); +}); + +describe('modal', () => { + it('changing environment variables persists to target-scope', () => { + storageGlobalSet(data.globalEnvarsJson); + visitTargetDevelopment(); + openPreflightTab(); + openPreflightModal(); + cy.contains(data.globalEnvarsJson); + setMonacoEditorContents('env-editor', data.targetEnvarsJson); + storageTargetDevelopmentGet().should('equal', data.targetEnvarsJson); + cy.contains(data.targetEnvarsJson); + visitTargetProduction(); + openPreflightTab(); + cy.contains(data.globalEnvarsJson); + }); +}); + +// todo: in another PR this utility is factored out into a shared file +/** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ +export function setMonacoEditorContents(editorCyName: string, text: string) { + // wait for textarea appearing which indicates monaco is loaded + cy.dataCy(editorCyName).find('textarea'); + cy.window().then(win => { + // First, check if monaco is available on the main window + const editor = (win as any).monaco.editor + .getEditors() + .find(e => e.getContainerDomNode().parentElement.getAttribute('data-cy') === editorCyName); + + // If Monaco instance is found + if (editor) { + editor.setValue(text); + } else { + throw new Error('Monaco editor not found on the window or frames[0]'); + } + }); +} diff --git a/cypress/e2e/laboratory-preflight.cy.ts b/cypress/e2e/laboratory-preflight.cy.ts index 645dd3931f..50f69cf21e 100644 --- a/cypress/e2e/laboratory-preflight.cy.ts +++ b/cypress/e2e/laboratory-preflight.cy.ts @@ -17,15 +17,15 @@ const selectors = { }, }; -const data: { slug: string } = { - slug: '', +const ctx = { + targetSlug: '', }; beforeEach(() => { cy.clearLocalStorage().then(async () => { cy.task('seedTarget').then(({ slug, refreshToken }: any) => { cy.setCookie('sRefreshToken', refreshToken); - data.slug = slug; + ctx.targetSlug = slug; cy.visit(`/${slug}/laboratory`); cy.get(selectors.buttonGraphiQLPreflight).click(); }); @@ -33,7 +33,7 @@ beforeEach(() => { }); /** Helper function for setting the text within a monaco editor as typing manually results in flaky tests */ -function setMonacoEditorContents(editorCyName: string, text: string) { +export function setMonacoEditorContents(editorCyName: string, text: string) { // wait for textarea appearing which indicates monaco is loaded cy.dataCy(editorCyName).find('textarea'); cy.window().then(win => { @@ -59,7 +59,7 @@ describe('Laboratory > Preflight Script', () => { // https://github.com/graphql-hive/console/pull/6450 it('regression: loads even if local storage is set to {}', () => { window.localStorage.setItem('hive:laboratory:environment', '{}'); - cy.visit(`/${data.slug}/laboratory`); + cy.visit(`/${ctx.targetSlug}/laboratory`); cy.get(selectors.buttonGraphiQLPreflight).click(); }); it('mini script editor is read only', () => { diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 7149352f73..94986a1df5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,4 +1,5 @@ import './commands'; +import 'cypress-localstorage-commands'; Cypress.on('uncaught:exception', (_err, _runnable) => { return false; diff --git a/cypress/support/testkit.ts b/cypress/support/testkit.ts index 44f20faae2..01827e4a1d 100644 --- a/cypress/support/testkit.ts +++ b/cypress/support/testkit.ts @@ -1,3 +1,76 @@ +export const as = <$Type>() => undefined as $Type; + +export type { Target } from '../../integration-tests/testkit/seed'; + +// todo: instead of copying this, import it from core utility lib. +export const environmentVariablesStorageKey = { + // todo: optional target effectively gives this the possibility of being silently global + // which feels subtle and thus likely to introduce hard to trace defects. Should we abort instead? + scoped: (targetId?: string) => + `hive/targetId:${targetId ?? '__null__'}/laboratory/environment-variables`, + global: 'hive:laboratory:environment', +}; + +// todo: Once other PRs are merged these selectors will be scoped to a place for laboratory. +export const selectors = { + editorEnvironmentVariables: '[data-cy="preflight-editor-mini"]', + buttonGraphiQLPreflight: '[aria-label*="Preflight Script"]', + buttonModalCy: 'preflight-modal-button', + buttonToggleCy: 'toggle-preflight', + buttonHeaders: '[data-name="headers"]', + headersEditor: { + textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea', + }, + graphiql: { + buttonExecute: '.graphiql-execute-button', + }, + + modal: { + buttonSubmitCy: 'preflight-modal-submit', + }, +}; + +export function persistAuthenticationCookies() { + const ctx = { + cookies: [] as Cypress.Cookie[], + }; + + before(() => { + cy.getCookie('sRefreshToken').should('exist'); + cy.visit('/'); + cy.wait(2000); + + cy.getCookie('sAccessToken').should('exist'); + cy.getCookie('sFrontToken').should('exist'); + cy.getCookie('st-last-access-token-update').should('exist'); + + cy.getCookie('sAccessToken').then(sAccessToken => { + ctx.cookies.push(sAccessToken); + }); + cy.getCookie('sFrontToken').then(sFrontToken => { + ctx.cookies.push(sFrontToken); + }); + cy.getCookie('sRefreshToken').then(sRefreshToken => { + ctx.cookies.push(sRefreshToken); + }); + + cy.getCookie('st-last-access-token-update').then(stLastAccessTokenUpdate => { + ctx.cookies.push(stLastAccessTokenUpdate); + }); + + cy.clearCookie('st-last-access-token-update'); + cy.clearCookie('sRefreshToken'); + cy.clearCookie('sAccessToken'); + cy.clearCookie('sFrontToken'); + }); + + beforeEach(() => { + ctx.cookies.forEach(cookie => { + cy.setCookie(cookie.name, cookie.value, cookie); + }); + }); +} + export function generateRandomSlug() { return Math.random().toString(36).substring(2); } diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index d48dc568dc..4335e61b35 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -51,8 +51,6 @@ import { import * as GraphQLSchema from './gql/graphql'; import { BreakingChangeFormula, - OrganizationAccessScope, - ProjectAccessScope, ProjectType, SchemaPolicyInput, TargetAccessScope, @@ -62,6 +60,12 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from import { collect, CollectedOperation, legacyCollect } from './usage'; import { generateUnique } from './utils'; +export interface Target { + id: string; + path: string; + slug: string; +} + export function initSeed() { function createConnectionPool() { const pg = { @@ -210,9 +214,12 @@ export function initSeed() { ownerToken, ).then(r => r.expectNoGraphQLErrors()); - const targets = projectResult.createProject.ok!.createdTargets; - const target = targets[0]; const project = projectResult.createProject.ok!.createdProject; + const targets = projectResult.createProject.ok!.createdTargets.map(target => ({ + ...target, + path: `/${organization.slug}/${project.slug}/${target.slug}`, + })); + const target = targets[0]; return { project, diff --git a/package.json b/package.json index f5b68dfe7f..03f90fecf3 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@types/node": "22.10.5", "bob-the-bundler": "7.0.1", "cypress": "13.17.0", + "cypress-localstorage-commands": "^2.2.7", "dotenv": "16.4.7", "eslint": "8.57.1", "eslint-plugin-cypress": "4.1.0", diff --git a/packages/web/app/src/lib/hooks/use-local-storage-json.ts b/packages/web/app/src/lib/hooks/use-local-storage-json.ts index 97bd5a4465..e0e9c78b3b 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage-json.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage-json.ts @@ -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. // @@ -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; @@ -49,10 +52,10 @@ 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; @@ -60,8 +63,8 @@ export function useLocalStorageJson<$Schema extends z.ZodType>(...args: ArgsInpu type ArgsInput<$Schema extends z.ZodType> = $Schema extends z.ZodDefault - ? [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 @@ -69,7 +72,9 @@ type ArgsInputGuardZodJsonSchema<$Schema extends z.ZodType> = : '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; diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index db38bae855..75f71e1871 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -1,17 +1,35 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { + readVersionedEntryLocalStorage, + serializeEntrySpec, + serializeVersionedEntrySpec, + VersionedEntrySpec, +} from '../versioned-entry'; -export function useLocalStorage(key: string, defaultValue: string) { - const [value, setValue] = useState(() => { - const value = localStorage.getItem(key); +export function useLocalStorage(key: string | VersionedEntrySpec, defaultValue: string) { + const versionedEntry: VersionedEntrySpec = typeof key === 'string' ? [{ key }] : key; + const versionedEntrySerialized = serializeVersionedEntrySpec(versionedEntry); + + const versionedEntryLatest = versionedEntry[0]; + const versionedEntryLatestSerialized = serializeEntrySpec(versionedEntryLatest); + + const getInitialValue = useCallback(() => { + const value = readVersionedEntryLocalStorage({ spec: versionedEntry }); return value ?? defaultValue; - }); + }, [versionedEntrySerialized, defaultValue]); + + const [value, setValue] = useState(getInitialValue()); + + useEffect(() => { + setValue(getInitialValue()); + }, [getInitialValue]); const set = useCallback( (value: string) => { - localStorage.setItem(key, value); + localStorage.setItem(versionedEntryLatest.key, value); setValue(value); }, - [setValue], + [versionedEntryLatestSerialized], ); return [value, set] as const; diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx index 30fb4f595a..1f96fe0c69 100644 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx @@ -144,6 +144,14 @@ export const enum PreflightWorkerState { ready, } +export const environmentVariablesStorageKey = { + // todo: optional target effectively gives this the possibility of being silently global + // which feels subtle and thus likely to introduce hard to trace defects. Should we abort instead? + scoped: (targetId?: string) => + `hive/targetId:${targetId ?? '__null__'}/laboratory/environment-variables`, + global: 'hive:laboratory:environment', +}; + export function usePreflight(args: { target: FragmentType | null; }) { @@ -152,13 +160,14 @@ 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. - // 'hive:laboratory:isPreflightEnabled', 'hive:laboratory:isPreflightScriptEnabled', z.boolean().default(false), ); const [environmentVariables, setEnvironmentVariables] = useLocalStorage( - 'hive:laboratory:environment', + [ + { key: environmentVariablesStorageKey.scoped(target?.id) }, + { key: environmentVariablesStorageKey.global }, + ], '', ); const latestEnvironmentVariablesRef = useRef(environmentVariables); diff --git a/packages/web/app/src/lib/versioned-entry.spec.ts b/packages/web/app/src/lib/versioned-entry.spec.ts new file mode 100644 index 0000000000..1793127092 --- /dev/null +++ b/packages/web/app/src/lib/versioned-entry.spec.ts @@ -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([ + // 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) + }, +); diff --git a/packages/web/app/src/lib/versioned-entry.ts b/packages/web/app/src/lib/versioned-entry.ts new file mode 100644 index 0000000000..d131ad094f --- /dev/null +++ b/packages/web/app/src/lib/versioned-entry.ts @@ -0,0 +1,134 @@ +// -------------------------------------------------------------------- +// KeyValueStore Interface +// -------------------------------------------------------------------- + +export interface KeyValueStore { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} + +export type KeyValueStoreDatabase = Record; + +// -------------------------------------------------------------------- +// Versioned Entry Data Types +// -------------------------------------------------------------------- + +export type VersionedEntrySpec = readonly [EntrySpec, ...(readonly EntrySpec[])]; + +export const serializeVersionedEntrySpec = (versionedEntrySpec: VersionedEntrySpec) => + versionedEntrySpec.map(serializeEntrySpec).join(','); + +interface EntrySpec { + key: string; + // todo once we have use-case + // schema: + // fromPrevious: +} + +export const serializeEntrySpec = (entrySpec: EntrySpec) => entrySpec.key; + +// -------------------------------------------------------------------- +// 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); + } + } + + 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]; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 034adb7913..9126297b15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: cypress: specifier: 13.17.0 version: 13.17.0 + cypress-localstorage-commands: + specifier: ^2.2.7 + version: 2.2.7(cypress@13.17.0) dotenv: specifier: 16.4.7 version: 16.4.7 @@ -9053,6 +9056,12 @@ packages: csv-stringify@6.5.2: resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + cypress-localstorage-commands@2.2.7: + resolution: {integrity: sha512-hUe6hz/3TD9Ph70CUHJLSiTzL0INikUQ4W3CRd7XmaGCDjwR6jGAlvTCGmxZ6yGc4Mq/WN6L8xJAu+dOrIvYCA==} + engines: {node: '>=14.0.0'} + peerDependencies: + cypress: '>=2.1.0' + cypress@13.17.0: resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} @@ -16235,8 +16244,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16343,11 +16352,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16386,6 +16395,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16519,11 +16529,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16562,7 +16572,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -16676,7 +16685,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -16795,7 +16804,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -16970,7 +16979,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -25584,6 +25593,10 @@ snapshots: csv-stringify@6.5.2: {} + cypress-localstorage-commands@2.2.7(cypress@13.17.0): + dependencies: + cypress: 13.17.0 + cypress@13.17.0: dependencies: '@cypress/request': 3.0.6