diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 85807c683714a..77e5cfed6ee67 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -349,11 +349,13 @@ src/core/packages/user-profile/server-mocks @elastic/kibana-core src/core/packages/user-settings/server @elastic/kibana-security src/core/packages/user-settings/server-internal @elastic/kibana-security src/core/packages/user-settings/server-mocks @elastic/kibana-security +src/core/packages/user-storage/browser @elastic/appex-sharedux +src/core/packages/user-storage/browser-internal @elastic/appex-sharedux +src/core/packages/user-storage/browser-mocks @elastic/appex-sharedux src/core/packages/user-storage/common @elastic/appex-sharedux src/core/packages/user-storage/server @elastic/appex-sharedux src/core/packages/user-storage/server-internal @elastic/appex-sharedux src/core/packages/user-storage/server-mocks @elastic/appex-sharedux -src/core/packages/user-storage/test/plugins/user_storage_test @elastic/appex-sharedux src/core/test-helpers/kbn-server @elastic/kibana-core src/core/test-helpers/model-versions @elastic/kibana-core src/platform/kbn-ui/chrome-layout @elastic/appex-sharedux @@ -927,6 +929,7 @@ src/platform/test/plugin_functional/plugins/ui_settings_plugin @elastic/kibana-c src/platform/test/plugin_functional/plugins/usage_collection @elastic/kibana-core src/platform/test/server_integration/plugins/status_plugin_a @elastic/kibana-core src/platform/test/server_integration/plugins/status_plugin_b @elastic/kibana-core +src/platform/test/user_storage/plugins/user_storage_test @elastic/appex-sharedux src/setup_node_env @elastic/kibana-operations x-pack/examples/alerting_example @elastic/response-ops x-pack/examples/embedded_lens_example @elastic/kibana-visualizations diff --git a/package.json b/package.json index 0cb6d67008024..119e2bb431f5f 100644 --- a/package.json +++ b/package.json @@ -533,6 +533,8 @@ "@kbn/core-user-settings-server": "link:src/core/packages/user-settings/server", "@kbn/core-user-settings-server-internal": "link:src/core/packages/user-settings/server-internal", "@kbn/core-user-settings-server-mocks": "link:src/core/packages/user-settings/server-mocks", + "@kbn/core-user-storage-browser": "link:src/core/packages/user-storage/browser", + "@kbn/core-user-storage-browser-internal": "link:src/core/packages/user-storage/browser-internal", "@kbn/core-user-storage-common": "link:src/core/packages/user-storage/common", "@kbn/core-user-storage-server": "link:src/core/packages/user-storage/server", "@kbn/core-user-storage-server-internal": "link:src/core/packages/user-storage/server-internal", @@ -1244,7 +1246,7 @@ "@kbn/user-profile-components": "link:src/platform/packages/shared/kbn-user-profile-components", "@kbn/user-profile-examples-plugin": "link:examples/user_profile_examples", "@kbn/user-profiles-consumer-plugin": "link:x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer", - "@kbn/user-storage-test-plugin": "link:src/core/packages/user-storage/test/plugins/user_storage_test", + "@kbn/user-storage-test-plugin": "link:src/platform/test/user_storage/plugins/user_storage_test", "@kbn/utility-types": "link:src/platform/packages/shared/kbn-utility-types", "@kbn/utility-types-jest": "link:src/platform/packages/shared/kbn-utility-types-jest", "@kbn/utils": "link:src/platform/packages/shared/kbn-utils", @@ -1709,6 +1711,7 @@ "@kbn/core-ui-settings-server-mocks": "link:src/core/packages/ui-settings/server-mocks", "@kbn/core-usage-data-server-mocks": "link:src/core/packages/usage-data/server-mocks", "@kbn/core-user-activity-server-mocks": "link:src/core/packages/user-activity/server-mocks", + "@kbn/core-user-storage-browser-mocks": "link:src/core/packages/user-storage/browser-mocks", "@kbn/cypress-config": "link:src/platform/packages/shared/kbn-cypress-config", "@kbn/cypress-test-helper": "link:src/platform/packages/shared/kbn-cypress-test-helper", "@kbn/dependency-ownership": "link:packages/kbn-dependency-ownership", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index f574f2754cf20..892a99934dca9 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -29,7 +29,7 @@ pageLoadAssetSize: contentConnectors: 33014 contentManagement: 8350 controls: 10300 - core: 547151 + core: 548600 cps: 9209 crossClusterReplication: 12662 customIntegrations: 11715 diff --git a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts index e933b806cd161..7ad33367b9239 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.test.ts @@ -195,3 +195,17 @@ describe('setup.getFeatureFlags()', () => { expect(contract.getFeatureFlags()).toBeUndefined(); }); }); + +describe('setup.getUserStorage()', () => { + it('returns injectedMetadata.userStorage', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + userStorage: { values: { 'navigation:layout': { hidden: ['discover'] } } }, + }, + } as unknown as InjectedMetadataParams); + + expect(injectedMetadata.setup().getUserStorage()).toEqual({ + values: { 'navigation:layout': { hidden: ['discover'] } }, + }); + }); +}); diff --git a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts index b9594b9f042e3..22e8f911b1581 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/injected_metadata_service.ts @@ -99,6 +99,10 @@ export class InjectedMetadataService { getFeatureFlags: () => { return this.state.featureFlags; }, + + getUserStorage: () => { + return this.state.userStorage; + }, }; } } diff --git a/src/core/packages/injected-metadata/browser-internal/src/types.ts b/src/core/packages/injected-metadata/browser-internal/src/types.ts index 07f26644b53e0..a3100377021a0 100644 --- a/src/core/packages/injected-metadata/browser-internal/src/types.ts +++ b/src/core/packages/injected-metadata/browser-internal/src/types.ts @@ -64,6 +64,9 @@ export interface InternalInjectedMetadataSetup { initialFeatureFlags: Record; } | undefined; + getUserStorage: () => { + values: Record; + }; } /** @internal */ diff --git a/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts b/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts index 032b3819ee7bd..e9dd9698cf8da 100644 --- a/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts +++ b/src/core/packages/injected-metadata/browser-mocks/src/injected_metadata_service.mock.ts @@ -54,6 +54,7 @@ const createSetupContractMock = () => { getKibanaBuildNumber: jest.fn(), getCustomBranding: jest.fn(), getFeatureFlags: jest.fn(), + getUserStorage: jest.fn().mockReturnValue({ values: {} }), }); return setupContract; diff --git a/src/core/packages/injected-metadata/common-internal/src/types.ts b/src/core/packages/injected-metadata/common-internal/src/types.ts index fad41395a921c..4dd9f115a0c95 100644 --- a/src/core/packages/injected-metadata/common-internal/src/types.ts +++ b/src/core/packages/injected-metadata/common-internal/src/types.ts @@ -93,4 +93,7 @@ export interface InjectedMetadata { }; }; customBranding: Pick; + userStorage: { + values: Record; + }; } diff --git a/src/core/packages/lifecycle/browser-mocks/moon.yml b/src/core/packages/lifecycle/browser-mocks/moon.yml index dc3b1375236bd..a4610b0bfd0f2 100644 --- a/src/core/packages/lifecycle/browser-mocks/moon.yml +++ b/src/core/packages/lifecycle/browser-mocks/moon.yml @@ -37,6 +37,7 @@ dependsOn: - '@kbn/core-rendering-browser-mocks' - '@kbn/core-pricing-browser-mocks' - '@kbn/core-di-mocks' + - '@kbn/core-user-storage-browser-mocks' - '@kbn/lazy-object' tags: - shared-browser diff --git a/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts b/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts index 39b5d23b1efbb..08f992cd5bc40 100644 --- a/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts +++ b/src/core/packages/lifecycle/browser-mocks/src/core_setup.mock.ts @@ -24,6 +24,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import { createCoreStartMock } from './core_start.mock'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; import { lazyObject } from '@kbn/lazy-object'; export function createCoreSetupMock({ @@ -56,6 +57,7 @@ export function createCoreSetupMock({ theme: themeServiceMock.createSetupContract(), security: securityServiceMock.createSetup(), userProfile: userProfileServiceMock.createSetup(), + userStorage: userStorageServiceMock.createSetupContract(), plugins: lazyObject({ onSetup: jest.fn(), onStart: jest.fn(), diff --git a/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts b/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts index 9fc8938f61bab..11ac7a52ee1b6 100644 --- a/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts +++ b/src/core/packages/lifecycle/browser-mocks/src/core_start.mock.ts @@ -27,6 +27,7 @@ import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-browser-mocks'; import { pricingServiceMock } from '@kbn/core-pricing-browser-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; import { lazyObject } from '@kbn/lazy-object'; export function createCoreStartMock({ basePath = '' } = {}) { @@ -52,6 +53,7 @@ export function createCoreStartMock({ basePath = '' } = {}) { userProfile: userProfileServiceMock.createStart(), rendering: renderingServiceMock.create(), pricing: pricingServiceMock.createStartContract(), + userStorage: userStorageServiceMock.createStartContract(), plugins: lazyObject({ onStart: jest.fn(), }), diff --git a/src/core/packages/lifecycle/browser-mocks/tsconfig.json b/src/core/packages/lifecycle/browser-mocks/tsconfig.json index 87c10fe292a78..2fab96184f6f1 100644 --- a/src/core/packages/lifecycle/browser-mocks/tsconfig.json +++ b/src/core/packages/lifecycle/browser-mocks/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-rendering-browser-mocks", "@kbn/core-pricing-browser-mocks", "@kbn/core-di-mocks", + "@kbn/core-user-storage-browser-mocks", "@kbn/lazy-object", ], "exclude": [ diff --git a/src/core/packages/lifecycle/browser/moon.yml b/src/core/packages/lifecycle/browser/moon.yml index 280d0ac2ed7e2..b225c2490e35c 100644 --- a/src/core/packages/lifecycle/browser/moon.yml +++ b/src/core/packages/lifecycle/browser/moon.yml @@ -37,6 +37,7 @@ dependsOn: - '@kbn/core-feature-flags-browser' - '@kbn/core-rendering-browser' - '@kbn/core-pricing-browser' + - '@kbn/core-user-storage-browser' - '@kbn/core-di' tags: - shared-browser diff --git a/src/core/packages/lifecycle/browser/src/core_setup.ts b/src/core/packages/lifecycle/browser/src/core_setup.ts index ce55d19ad3737..2deb91ccd1b23 100644 --- a/src/core/packages/lifecycle/browser/src/core_setup.ts +++ b/src/core/packages/lifecycle/browser/src/core_setup.ts @@ -22,6 +22,7 @@ import type { CustomBrandingSetup } from '@kbn/core-custom-branding-browser'; import type { PluginsServiceSetup } from '@kbn/core-plugins-contracts-browser'; import type { SecurityServiceSetup } from '@kbn/core-security-browser'; import type { UserProfileServiceSetup } from '@kbn/core-user-profile-browser'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; import type { CoreStart } from './core_start'; /** @@ -72,6 +73,8 @@ export interface CoreSetup = {}, TStar security: SecurityServiceSetup; /** {@link UserProfileServiceSetup} */ userProfile: UserProfileServiceSetup; + /** {@link IUserStorageClient} */ + userStorage: IUserStorageClient; /** {@link StartServicesAccessor} */ getStartServices: StartServicesAccessor; } diff --git a/src/core/packages/lifecycle/browser/src/core_start.ts b/src/core/packages/lifecycle/browser/src/core_start.ts index f1b5d9c1ecf63..f6a1eebf719e8 100644 --- a/src/core/packages/lifecycle/browser/src/core_start.ts +++ b/src/core/packages/lifecycle/browser/src/core_start.ts @@ -28,6 +28,7 @@ import type { SecurityServiceStart } from '@kbn/core-security-browser'; import type { RenderingService } from '@kbn/core-rendering-browser'; import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser'; import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; /** * Core services exposed to the `Plugin` start lifecycle @@ -82,6 +83,8 @@ export interface CoreStart { security: SecurityServiceStart; /** {@link UserProfileServiceStart} */ userProfile: UserProfileServiceStart; + /** {@link IUserStorageClient} */ + userStorage: IUserStorageClient; /** {@link RenderingService} */ rendering: RenderingService; } diff --git a/src/core/packages/lifecycle/browser/tsconfig.json b/src/core/packages/lifecycle/browser/tsconfig.json index 9d98a96e6d932..19c094ac4eeaa 100644 --- a/src/core/packages/lifecycle/browser/tsconfig.json +++ b/src/core/packages/lifecycle/browser/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-feature-flags-browser", "@kbn/core-rendering-browser", "@kbn/core-pricing-browser", + "@kbn/core-user-storage-browser", "@kbn/core-di", ], "exclude": [ diff --git a/src/core/packages/plugins/browser-internal/src/plugin_context.ts b/src/core/packages/plugins/browser-internal/src/plugin_context.ts index 8d849b312ae44..9886910e57ba8 100644 --- a/src/core/packages/plugins/browser-internal/src/plugin_context.ts +++ b/src/core/packages/plugins/browser-internal/src/plugin_context.ts @@ -106,6 +106,7 @@ export function createPluginSetupContext< registerUserProfileDelegate: (delegate) => deps.userProfile.registerUserProfileDelegate(delegate), }, + userStorage: deps.userStorage, plugins: { onSetup: (...dependencyNames) => runtimeResolver.onSetup(plugin.name, dependencyNames), onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames), @@ -178,6 +179,7 @@ export function createPluginStartContext< authc: deps.security.authc, }, userProfile: deps.userProfile, + userStorage: deps.userStorage, plugins: { onStart: (...dependencyNames) => runtimeResolver.onStart(plugin.name, dependencyNames), }, diff --git a/src/core/packages/rendering/server-internal/moon.yml b/src/core/packages/rendering/server-internal/moon.yml index 8468c56a014ad..52758453f5176 100644 --- a/src/core/packages/rendering/server-internal/moon.yml +++ b/src/core/packages/rendering/server-internal/moon.yml @@ -52,6 +52,8 @@ dependsOn: - '@kbn/core-feature-flags-server-internal' - '@kbn/core-feature-flags-server-mocks' - '@kbn/core-feature-flags-server' + - '@kbn/core-user-storage-server' + - '@kbn/core-user-storage-server-mocks' - '@kbn/repo-info' tags: - shared-server diff --git a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap index 3937764b33eb9..e366ea112c3ab 100644 --- a/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/packages/rendering/server-internal/src/__snapshots__/rendering_service.test.ts.snap @@ -91,6 +91,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -182,6 +185,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -277,6 +283,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -368,6 +377,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -459,6 +471,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -554,6 +569,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -645,6 +663,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -736,6 +757,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -827,6 +851,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -918,6 +945,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1018,6 +1048,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1111,6 +1144,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1211,6 +1247,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1307,6 +1346,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1398,6 +1440,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1498,6 +1543,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1594,6 +1642,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1690,6 +1741,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1788,6 +1842,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; @@ -1886,6 +1943,9 @@ Object { "version": "v8", }, "uiPlugins": Array [], + "userStorage": Object { + "values": Object {}, + }, "version": Any, } `; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts index 3ac30347279fc..81b3409f4afbf 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.test.ts +++ b/src/core/packages/rendering/server-internal/src/rendering_service.test.ts @@ -761,6 +761,73 @@ describe('RenderingService', () => { }); }); + describe('userStorage injection', () => { + const renderAndReadUserStorage = async (content: string) => { + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + return data.userStorage; + }; + + const buildUiSettings = () => ({ + client: uiSettingsServiceMock.createClient(), + globalClient: uiSettingsServiceMock.createClient(), + }); + + it('injects values returned by userStorage.asScoped().getForInjection()', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const getForInjection = jest + .fn() + .mockResolvedValue({ 'navigation:layout': { hidden: ['discover'] } }); + const asScoped = jest.fn().mockReturnValue({ getForInjection }); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(asScoped).toHaveBeenCalledTimes(1); + expect(getForInjection).toHaveBeenCalledTimes(1); + expect(await renderAndReadUserStorage(content)).toEqual({ + values: { 'navigation:layout': { hidden: ['discover'] } }, + }); + }); + + it('injects empty values when asScoped() returns null (no profile_uid)', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const asScoped = jest.fn().mockReturnValue(null); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings()); + + expect(asScoped).toHaveBeenCalledTimes(1); + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('injects empty values for anonymous pages without consulting userStorage', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const asScoped = jest.fn(); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + const content = await render(createKibanaRequest(), buildUiSettings(), { + isAnonymousPage: true, + }); + + expect(asScoped).not.toHaveBeenCalled(); + expect(await renderAndReadUserStorage(content)).toEqual({ values: {} }); + }); + + it('rejects when getForInjection() rejects', async () => { + const { render } = await service.setup(mockRenderingSetupDeps); + + const getForInjection = jest.fn().mockRejectedValue(new Error('ES exploded')); + const asScoped = jest.fn().mockReturnValue({ getForInjection }); + service.start({ ...mockRenderingStartDeps, userStorage: { asScoped } }); + + await expect(render(createKibanaRequest(), buildUiSettings())).rejects.toThrow('ES exploded'); + }); + }); + describe('rspack mode metadata', () => { let rspackService: RenderingService; diff --git a/src/core/packages/rendering/server-internal/src/rendering_service.tsx b/src/core/packages/rendering/server-internal/src/rendering_service.tsx index 881f69acab219..77944b336488e 100644 --- a/src/core/packages/rendering/server-internal/src/rendering_service.tsx +++ b/src/core/packages/rendering/server-internal/src/rendering_service.tsx @@ -18,6 +18,7 @@ import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; +import type { UserStorageServiceStart } from '@kbn/core-user-storage-server'; import { type DarkModeValue, type ThemeName, @@ -71,6 +72,7 @@ export class RenderingService { private readonly themeName$ = new BehaviorSubject(DEFAULT_THEME_NAME); private airgapped: boolean = false; private isCoreRenderingInReactConcurrentMode: boolean = true; + private userStorageStart?: UserStorageServiceStart; constructor(private readonly coreContext: CoreContext) {} public async preboot({ @@ -140,7 +142,8 @@ export class RenderingService { }; } - public start({ featureFlags }: RenderingStartDeps) { + public start({ featureFlags, userStorage }: RenderingStartDeps) { + this.userStorageStart = userStorage; featureFlags .getStringValue$(DEFAULT_THEME_NAME_FEATURE_FLAG, DEFAULT_THEME_NAME) // Parse the input feature flag value to ensure it's of type ThemeName @@ -197,6 +200,7 @@ export class RenderingService { globalSettingsUserValues = {}, userSettingDarkMode, userSettingLocale, + userStorageValues = {}, ] = await Promise.all( isAnonymousPage ? [uiSettings.client?.getRegistered() ?? {}] @@ -208,12 +212,15 @@ export class RenderingService { userSettings?.getUserSettingDarkMode(request), // locale userSettings?.getUserSettingLocale(request), + // user storage + this.fetchUserStorageValues(request), ] as [ ReturnType, Promise>, Promise>, Promise | undefined, - Promise | undefined + Promise | undefined, + Promise> ]) ); @@ -385,6 +392,7 @@ export class RenderingService { uiSettings: settings, globalUiSettings: globalSettings, }, + userStorage: { values: userStorageValues }, }, }; @@ -392,6 +400,16 @@ export class RenderingService { } public async stop() {} + + private async fetchUserStorageValues(request: KibanaRequest): Promise> { + const userStorage = this.userStorageStart; + if (!userStorage) return {}; + + const client = userStorage.asScoped(request); + if (!client) return {}; + + return client.getForInjection(); + } } const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => { diff --git a/src/core/packages/rendering/server-internal/src/test_helpers/params.ts b/src/core/packages/rendering/server-internal/src/test_helpers/params.ts index 05483393c0c36..15942e23ed222 100644 --- a/src/core/packages/rendering/server-internal/src/test_helpers/params.ts +++ b/src/core/packages/rendering/server-internal/src/test_helpers/params.ts @@ -16,6 +16,7 @@ import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mock import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks'; import { i18nServiceMock } from '@kbn/core-i18n-server-mocks'; import { coreFeatureFlagsMock } from '@kbn/core-feature-flags-server-mocks'; +import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks'; const context = mockCoreContext.create(); // Mock the airgapped config to return a boolean value @@ -53,4 +54,5 @@ export const mockRenderingSetupDeps = { }; export const mockRenderingStartDeps = { featureFlags: coreFeatureFlagsMock.createStart(), + userStorage: userStorageServiceMock.createStartContract(), }; diff --git a/src/core/packages/rendering/server-internal/src/types.ts b/src/core/packages/rendering/server-internal/src/types.ts index cdaeff3abe979..e9c89cd0f853a 100644 --- a/src/core/packages/rendering/server-internal/src/types.ts +++ b/src/core/packages/rendering/server-internal/src/types.ts @@ -26,6 +26,7 @@ import type { I18nServiceSetup } from '@kbn/core-i18n-server'; import type { InternalI18nServicePreboot } from '@kbn/core-i18n-server-internal'; import type { InternalFeatureFlagsSetup } from '@kbn/core-feature-flags-server-internal'; import type { FeatureFlagsStart } from '@kbn/core-feature-flags-server'; +import type { UserStorageServiceStart } from '@kbn/core-user-storage-server'; /** @internal */ export interface RenderingMetadata { @@ -68,6 +69,8 @@ export interface RenderingSetupDeps { /** @internal */ export interface RenderingStartDeps { featureFlags: FeatureFlagsStart; + /** Optional so `render()` is safe to call before `start()` runs. */ + userStorage?: UserStorageServiceStart; } /** @internal */ diff --git a/src/core/packages/rendering/server-internal/tsconfig.json b/src/core/packages/rendering/server-internal/tsconfig.json index 169ab59a880b6..38ec4bc405f8c 100644 --- a/src/core/packages/rendering/server-internal/tsconfig.json +++ b/src/core/packages/rendering/server-internal/tsconfig.json @@ -48,6 +48,8 @@ "@kbn/core-feature-flags-server-internal", "@kbn/core-feature-flags-server-mocks", "@kbn/core-feature-flags-server", + "@kbn/core-user-storage-server", + "@kbn/core-user-storage-server-mocks", "@kbn/repo-info", ], "exclude": [ diff --git a/src/core/packages/root/browser-internal/moon.yml b/src/core/packages/root/browser-internal/moon.yml index d1f72c96965d7..9a94eb5de4cd3 100644 --- a/src/core/packages/root/browser-internal/moon.yml +++ b/src/core/packages/root/browser-internal/moon.yml @@ -79,6 +79,7 @@ dependsOn: - '@kbn/core-di-browser-internal' - '@kbn/core-di-mocks' - '@kbn/core-notifications-browser' + - '@kbn/core-user-storage-browser-internal' tags: - shared-browser - package diff --git a/src/core/packages/root/browser-internal/src/core_system.ts b/src/core/packages/root/browser-internal/src/core_system.ts index 0537abd2c7149..6e6bb4094994c 100644 --- a/src/core/packages/root/browser-internal/src/core_system.ts +++ b/src/core/packages/root/browser-internal/src/core_system.ts @@ -43,6 +43,7 @@ import { PricingService } from '@kbn/core-pricing-browser-internal'; import { CustomBrandingService } from '@kbn/core-custom-branding-browser-internal'; import { SecurityService } from '@kbn/core-security-browser-internal'; import { UserProfileService } from '@kbn/core-user-profile-browser-internal'; +import { UserStorageService } from '@kbn/core-user-storage-browser-internal'; import { version as REACT_VERSION } from 'react'; import { muteLegacyRootWarning } from '@kbn/react-mute-legacy-root-warning'; import { CoreInjectionService } from '@kbn/core-di-browser-internal'; @@ -115,6 +116,7 @@ export class CoreSystem { private readonly customBranding: CustomBrandingService; private readonly security: SecurityService; private readonly userProfile: UserProfileService; + private readonly userStorage: UserStorageService; private readonly pricing: PricingService; private fatalErrorsSetup: FatalErrorsSetup | null = null; private overlayNavigationSubscription: Subscription | undefined; @@ -160,6 +162,7 @@ export class CoreSystem { this.httpRateLimiter = new HttpRateLimiterService(); this.uiSettings = new UiSettingsService(); this.settings = new SettingsService(); + this.userStorage = new UserStorageService(); this.overlay = new OverlayService(); this.chrome = new ChromeService({ browserSupportsCsp, @@ -270,6 +273,7 @@ export class CoreSystem { const chrome = this.chrome.setup({ analytics }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const settings = this.settings.setup({ http, injectedMetadata }); + const userStorage = this.userStorage.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings, analytics }); const customBranding = this.customBranding.setup({ injectedMetadata }); const application = this.application.setup({ http, analytics }); @@ -289,6 +293,7 @@ export class CoreSystem { theme, uiSettings, settings, + userStorage, executionContext, customBranding, security, @@ -323,6 +328,7 @@ export class CoreSystem { const injection = this.injection.start(); const uiSettings = this.uiSettings.start(); const settings = this.settings.start(); + const userStorage = this.userStorage.start(); const docLinks = this.docLinks.start({ injectedMetadata }); const http = this.http.start(); const i18n = this.i18n.start(); @@ -439,6 +445,7 @@ export class CoreSystem { overlays, uiSettings, settings, + userStorage, fatalErrors, deprecations, customBranding, @@ -509,6 +516,7 @@ export class CoreSystem { this.integrations.stop(); this.uiSettings.stop(); this.settings.stop(); + this.userStorage.stop(); this.chrome.stop(); this.i18n.stop(); this.application.stop(); diff --git a/src/core/packages/root/browser-internal/tsconfig.json b/src/core/packages/root/browser-internal/tsconfig.json index 226a8d983292f..3633d1a13ea8e 100644 --- a/src/core/packages/root/browser-internal/tsconfig.json +++ b/src/core/packages/root/browser-internal/tsconfig.json @@ -74,6 +74,7 @@ "@kbn/core-di-browser-internal", "@kbn/core-di-mocks", "@kbn/core-notifications-browser", + "@kbn/core-user-storage-browser-internal", ], "exclude": [ "target/**/*", diff --git a/src/core/packages/root/server-internal/src/server.ts b/src/core/packages/root/server-internal/src/server.ts index b5d00633da22a..84b6ef2ddaf96 100644 --- a/src/core/packages/root/server-internal/src/server.ts +++ b/src/core/packages/root/server-internal/src/server.ts @@ -619,6 +619,7 @@ export class Server { this.rendering.start({ featureFlags: featureFlagsStart, + userStorage: userStorageStart, }); this.coreStart = { diff --git a/src/core/packages/user-storage/README.mdx b/src/core/packages/user-storage/README.mdx new file mode 100644 index 0000000000000..5bc889ad58934 --- /dev/null +++ b/src/core/packages/user-storage/README.mdx @@ -0,0 +1,288 @@ +--- +id: kibUserStorageService +slug: /kibana-dev-docs/tutorials/user-storage-service +title: User Storage service +description: Per-user, per-space (or global) key/value storage for Kibana plugins, with synchronous reads at first paint. +date: 2026-05-07 +tags: ['kibana', 'dev', 'contributor', 'api docs', 'core', 'user storage', 'preferences'] +--- + +# User Storage Service + +A server-backed, per-user store for plugin state that should follow the user across browsers, devices, and cache clears. Plugins register keys at setup time with a Zod schema, a default value, and a scope (`'space'` or `'global'`); reads on both server and browser are schema-validated, reactive, and — on the browser — synchronous at first paint via rendering injection. + +> **The one-line rule of thumb:** if it's per-user data that should survive a device switch or a cache clear, register it in User Storage. Otherwise, reach for one of the systems described under [Adjacent systems](#adjacent-systems). + +User Storage exists because over 30 plugins were independently writing to `localStorage` for per-user state, each handling serialization, scoping, and validation differently. There was no canonical pattern for "small, schema-validated, per-user data that follows the user." The service fills exactly that gap; it does not replace `localStorage`, `uiSettings`, or ES User Profile, all of which remain the right choice for their respective use cases. + +> ⚠️ Requires an authenticated user with a `profile_uid`. Anonymous pages and API-key requests have no profile: reads return defaults and writes get `403`. Don't build features on User Storage if they need to work without a logged-in user. + +### When to use it + +Per-user preferences that should follow the user across browsers and devices. Example use cases include: + +- **Navigation customization** — reorder, hide, and group nav items per user, per space. +- **Per-user timezone or locale** — override the space-level default without affecting other users. +- **Query language preference** (KQL / Lucene / ES|QL). +- **"Remember last space"** — return to the last space on login. +- **Favorited time ranges** — a small per-user preference list. + +Per-space user state where the same user may want different settings in different spaces is the natural fit for `scope: 'space'`. Cross-space user preferences fit `scope: 'global'`. + +### Adjacent systems + +User Storage co-exists with three other per-user storage layers; pick the one that fits the data, not the one that's most familiar. + +| If the data is… | Use | Why | +|---|---|---| +| Ephemeral UI state — panel widths, collapsed/expanded states, sort orders, view-mode toggles, tour completions, callout dismissals | **`localStorage`** | Trivially re-set; no real cost when lost. Doesn't need to follow the user across devices. | +| Identity / personalization that might follow the user across Elastic Cloud products — dark mode, avatar | **ES User Profile** | ES enforces per-user isolation; field set is small and stable. The right home for portable identity data, not for arbitrary plugin-owned state. | +| Space-wide or cluster-wide admin-set defaults, *not* per-user | **`uiSettings`** | Admin-managed, scoped to a space or cluster. No user dimension. | +| Shared / multi-user data | **Saved Objects** | Has its own visibility and sharing model. | +| Large blobs (>~few KB) | **file service** | Saved Objects are not blob storage. | +| Frequently-changing high-volume telemetry | **analytics** | Persisting every event in a per-user SO is the wrong shape. | +| Settings that must work for unauthenticated users | **`localStorage`** | User Storage requires a `profile_uid`; there is no anonymous fallback today. | + +## Key concepts + +### Registration + +Every key must be **registered** at server setup with a Zod schema, a default value, and a scope. Reads of unregistered keys throw; writes of unregistered keys return `400`. Reads always succeed: if no value is stored, the registered default is returned. + +### Scope + +A key is scoped either to a single space or globally across spaces: + +- `'space'` — the value is stored per `(profile_uid, space_id)`. Use this for state that is meaningfully per-space (e.g. per-solution side-nav customization). +- `'global'` — the value is stored per `profile_uid`, ignoring the active space. Use this for cross-space user preferences (e.g. a one-time tour dismissal). + +Once you pick a scope for a key, changing it later is a breaking change for users with existing values. Choose deliberately. + +### Schema validation + +The Zod schema runs: + +1. At registration, against the `defaultValue` (catches drift between the default and the schema at boot). +2. At every `set`, against the incoming value (rejects invalid writes with `400`). +3. At every `get`, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. This means you can safely tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome. + +### Preloaded values + +By default, browser-side reads are **lazy**: the cache starts empty for a given key and the first `get(key)` / `get$(key)` call fires a `GET /internal/user_storage/{key}` request to hydrate it. While the request is in-flight `get(key)` returns `undefined` (or the provided `defaultValue`) and `get$(key)` emits `undefined` then emits again once the value arrives. + +For keys on the critical rendering path, opt in to **eager injection** by setting `preload: true` in the key's `UserStorageDefinition`. Core then calls `getForInjection()` during server-side rendering and embeds only the opted-in keys into `` under `userStorage.values`, so those values are available synchronously before the first React render without any in-browser fetch. + +## Registering keys (server) + +Register from your plugin's server-side `setup` lifecycle. The same `register()` call can include multiple keys. + +```typescript +import { z } from '@kbn/zod/v4'; +import type { Plugin, CoreSetup } from '@kbn/core/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.userStorage.register({ + 'myPlugin:nav-layout': { + schema: z.object({ + hidden: z.array(z.string()), + order: z.array(z.string()), + }), + defaultValue: { hidden: [], order: [] }, + scope: 'space', + preload: true, // embed in HTML at first paint; needed on the critical render path + }, + 'myPlugin:tour-dismissed': { + schema: z.boolean(), + defaultValue: false, + scope: 'global', + // preload omitted — lazy-loaded on first access + }, + }); + } + + public start() {} +} +``` + +Each key may only be registered once across all plugins. Duplicate registrations throw at boot. + +Registration also rejects top-level Zod schemas that accept **`null`** (reserved as the removal tombstone in saved objects) or **`undefined`** (reserved for “no cached value” on the client and unreliable over JSON, since `JSON.stringify({ value: undefined })` drops the `value` key). + +## Reading and writing on the server + +`asScoped(request)` returns a client bound to the authenticated user behind a `KibanaRequest`. It returns `null` when the request has no `profile_uid` (typically API-key authentication or anonymous pages); always check. + +```typescript +const client = core.userStorage.asScoped(request); +if (!client) { + return response.forbidden({ body: { message: 'User profile not available' } }); +} + +const layout = await client.get('myPlugin:nav-layout'); + +await client.set('myPlugin:nav-layout', { hidden: ['discover'], order: [] }); +await client.remove('myPlugin:tour-dismissed'); // resets the key to its default +``` + +Server reads always return the resolved value (user override or registered default), never `undefined`. Writes are validated against the registered schema and reject with a Zod error on mismatch. + +### HTTP routes + +Three internal routes are exposed for the browser client; consumers do not normally call them directly: + +| Method | Path | Body | Returns | +|--------|---------------------------------------|-------------------|------------------------------------------------------| +| `GET` | `/internal/user_storage/{key}` | — | `{ value }` — the stored value or registered default | +| `PUT` | `/internal/user_storage/{key}` | `{ value }` | `200` on success, `400` on validation | +| `DELETE` | `/internal/user_storage/{key}` | — | `200` on success | + +All three return `403` when the request has no `profile_uid`. + +## Using the service in the browser + +The browser surface is in `@kbn/core-user-storage-browser` and is exposed on `core.userStorage` at both `setup` and `start`. The client is **synchronous** for reads (cache-backed) and asynchronous for writes (HTTP-backed): + +```typescript +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +// somewhere with access to core: +const layout = core.userStorage.get('myPlugin:nav-layout', defaultLayout); +await core.userStorage.set('myPlugin:nav-layout', nextLayout); +await core.userStorage.remove('myPlugin:tour-dismissed'); +``` + +Pass a `defaultValue` to `get(key, default)` so that consumers never see `undefined`. For keys with `preload: true` the cache is pre-populated at first paint so `get` is truly synchronous. For lazy-loaded keys, the first `get` triggers a background fetch and returns `undefined` (or `defaultValue`) until the response arrives. + +The client also exposes RxJS observables for live updates and HTTP errors: + +```typescript +core.userStorage.get$('myPlugin:nav-layout').subscribe((layout) => { + // emits the cached value (or undefined) on subscribe, again when a lazy fetch lands, + // and again on every successful set/remove for this key +}); + +core.userStorage.getUpdate$().subscribe((update) => { + // fires on every successful set/remove across all keys (not on lazy fetches) + if (update.type === 'set') console.log(update.key, update.newValue); + if (update.type === 'remove') console.log(update.key, 'removed'); +}); + +core.userStorage.getHttpError$().subscribe((err) => { + // fires when a set/remove/lazy-fetch HTTP call fails +}); +``` + +### React hook + +Wrap the component tree that needs User Storage in a ``. The hook throws a clear error if no provider is mounted in the tree. + +```tsx +import { + UserStorageProvider, + useUserStorage, + useUserStorageClient, +} from '@kbn/core-user-storage-browser'; + +// In your application's mount root: +const App = ({ core }: { core: CoreStart }) => ( + + + +); + +// In a component: +const NavLayoutEditor = () => { + const [layout, setLayout] = useUserStorage( + 'myPlugin:nav-layout', + { hidden: [], order: [] } + ); + + return ( + + ); +}; +``` + +`useUserStorage(key, defaultValue?)` returns `[value, setter]`. The value reflects the synchronous cache and re-renders on change. The setter persists via HTTP and refreshes the cache on success. If the HTTP write fails, the cache is unchanged, the returned promise rejects, and the error is published to `getHttpError$`. + +For the less common operations — `remove`, `getUpdate$`, `getHttpError$` — reach for the underlying client: + +```tsx +const client = useUserStorageClient(); + +const onResetTour = () => client.remove('myPlugin:tour-dismissed'); +``` + +## Testing + +Use the mocks in `@kbn/core-user-storage-browser-mocks` (browser) and `@kbn/core-user-storage-server-mocks` (server) instead of stubbing `core.userStorage` by hand. + +```typescript +// Browser: +import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks'; + +const userStorage = userStorageServiceMock.createStartContract(); +userStorage.get.mockReturnValue({ hidden: ['discover'], order: [] }); + +render( + + + +); +``` + +```typescript +// Server: +import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks'; + +const userStorage = userStorageServiceMock.createStartContract(); +userStorage.asScoped.mockReturnValue({ + get: jest.fn().mockResolvedValue({ hidden: ['discover'], order: [] }), + getForInjection: jest.fn().mockResolvedValue({}), + set: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), +}); +``` + +The browser mock's `createStartContract()` returns a fully-mocked `IUserStorageClient` where every method is a `jest.fn()`. Override individual methods with `mockReturnValue` / `mockResolvedValue` per test. + +## Caveats and gotchas + +### No fallback for missing `profile_uid` + +If your feature must work for users without a profile (anonymous pages, API-key auth), do not depend on User Storage. The browser cache will be empty and writes will return `403`. Either layer over `localStorage` for those cases or design the feature so missing User Storage is acceptable (e.g. fall back to defaults). + +### Single-tab consistency + +The browser cache is a snapshot taken at page render. If a second tab writes a different value, the first tab will not see it until the next reload. Cross-tab synchronization is on the roadmap but is not implemented today. + +### No optimistic updates + +`set()` does **not** update the cache until the HTTP write succeeds. If your UI needs immediate visual feedback during the in-flight write, manage that intermediate state at the component level — e.g. with a separate "pending value" piece of `useState` — and only commit to the User Storage value on success. + +### Schema migrations + +There is no built-in migration framework. To evolve a schema: + +- **Widening** (e.g. adding an optional field) is safe — old values still parse. +- **Narrowing** (e.g. tightening a regex, changing required fields) will cause stored values that no longer match to be replaced by the registered default at read time, with a warning. This is forward-compatible but lossy for the affected user. +- For non-trivial migrations, register a new key (`myPlugin:nav-layout-v2`), copy values lazily on read, and deprecate the old key. + +### Don't store secrets + +User Storage values are persisted in unencrypted system Saved Objects. Treat them as user-readable; do not persist tokens, credentials, or anything that should be encrypted at rest. + +## Reference + +| Package | Visibility | Contents | +|-----------------------------------------------|------------|-----------------------------------------------------------------------| +| `@kbn/core-user-storage-common` | shared | `UserStorageDefinition`, `UserStorageScope`, server `IUserStorageClient` | +| `@kbn/core-user-storage-server` | shared | `UserStorageServiceSetup`, `UserStorageServiceStart` | +| `@kbn/core-user-storage-browser` | shared | Browser `IUserStorageClient`, `UserStorageProvider`, `useUserStorage`, `useUserStorageClient` | +| `@kbn/core-user-storage-server-mocks` | dev only | `userStorageServiceMock` (server) | +| `@kbn/core-user-storage-browser-mocks` | dev only | `userStorageServiceMock` (browser) | +| `@kbn/core-user-storage-server-internal` | private | Server implementation | +| `@kbn/core-user-storage-browser-internal` | private | Browser implementation | \ No newline at end of file diff --git a/src/core/packages/user-storage/browser-internal/index.ts b/src/core/packages/user-storage/browser-internal/index.ts new file mode 100644 index 0000000000000..9a1bf1f33c9c4 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { UserStorageService, UserStorageClient, UserStorageApi } from './src'; +export type { UserStorageServiceDeps, UserStorageClientParams } from './src'; diff --git a/src/core/packages/user-storage/browser-internal/jest.config.js b/src/core/packages/user-storage/browser-internal/jest.config.js new file mode 100644 index 0000000000000..6ae8a1846b332 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser-internal'], +}; diff --git a/src/core/packages/user-storage/browser-internal/kibana.jsonc b/src/core/packages/user-storage/browser-internal/kibana.jsonc new file mode 100644 index 0000000000000..be5584eb048fa --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser-internal", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "private" +} diff --git a/src/core/packages/user-storage/browser-internal/moon.yml b/src/core/packages/user-storage/browser-internal/moon.yml new file mode 100644 index 0000000000000..660f2e4bbae3b --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/moon.yml @@ -0,0 +1,38 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser-internal' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser-internal' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser-internal' + description: Moon project for @kbn/core-user-storage-browser-internal + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser-internal +dependsOn: + - '@kbn/core-http-browser-internal' + - '@kbn/core-http-browser-mocks' + - '@kbn/core-injected-metadata-browser-internal' + - '@kbn/core-injected-metadata-browser-mocks' + - '@kbn/core-user-storage-browser' +tags: + - shared-browser + - package + - prod + - group-platform + - private + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser-internal/package.json b/src/core/packages/user-storage/browser-internal/package.json new file mode 100644 index 0000000000000..8206c5d4324f5 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser-internal", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser-internal/src/index.ts b/src/core/packages/user-storage/browser-internal/src/index.ts new file mode 100644 index 0000000000000..397c8215990e1 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { UserStorageService } from './user_storage_service'; +export type { UserStorageServiceDeps } from './user_storage_service'; +export { UserStorageClient } from './user_storage_client'; +export type { UserStorageClientParams } from './user_storage_client'; +export { UserStorageApi } from './user_storage_api'; diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts new file mode 100644 index 0000000000000..d2117c34e3f6b --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { UserStorageApi } from './user_storage_api'; + +describe('UserStorageApi', () => { + const http = httpServiceMock.createSetupContract(); + let api: UserStorageApi; + + beforeEach(() => { + http.get.mockReset(); + http.put.mockReset(); + http.delete.mockReset(); + api = new UserStorageApi(http); + }); + + it('GETs /internal/user_storage/{key} and unwraps the value field', async () => { + http.get.mockResolvedValue({ value: { hidden: ['discover'] } }); + + const result = await api.get('navigation:layout'); + + expect(http.get).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout'); + expect(result).toEqual({ hidden: ['discover'] }); + }); + + it('PUTs /internal/user_storage/{key} with a value-wrapped body and returns the validated value', async () => { + const stored = { hidden: ['discover'] }; + http.put.mockResolvedValue({ value: stored }); + + const result = await api.set('navigation:layout', { hidden: ['discover'] }); + + expect(http.put).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout', { + body: JSON.stringify({ value: { hidden: ['discover'] } }), + }); + expect(result).toEqual(stored); + }); + + it('DELETEs /internal/user_storage/{key} for remove', async () => { + http.delete.mockResolvedValue(undefined); + + await api.remove('navigation:layout'); + + expect(http.delete).toHaveBeenCalledWith('/internal/user_storage/navigation%3Alayout'); + }); + + it('encodes special characters in keys', async () => { + http.put.mockResolvedValue({ value: 1 }); + + await api.set('a/b c', 1); + + expect(http.put).toHaveBeenCalledWith('/internal/user_storage/a%2Fb%20c', expect.any(Object)); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts new file mode 100644 index 0000000000000..e3b004bd88390 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_api.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { InternalHttpSetup } from '@kbn/core-http-browser-internal'; + +const BASE_PATH = '/internal/user_storage'; + +/** + * Thin HTTP wrapper over the user-storage internal routes. Each method maps + * to one HTTP round-trip; no caching. + * + * @internal + */ +export class UserStorageApi { + constructor(private readonly http: InternalHttpSetup) {} + + public async get(key: string): Promise { + const response = await this.http.get<{ value: unknown }>( + `${BASE_PATH}/${encodeURIComponent(key)}` + ); + return response.value; + } + + public async set(key: string, value: unknown): Promise { + const response = await this.http.put<{ value: unknown }>( + `${BASE_PATH}/${encodeURIComponent(key)}`, + { body: JSON.stringify({ value }) } + ); + return response.value; + } + + public async remove(key: string): Promise { + await this.http.delete(`${BASE_PATH}/${encodeURIComponent(key)}`); + } +} diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts new file mode 100644 index 0000000000000..1f8bb26cb4fd9 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject, firstValueFrom, lastValueFrom, take, toArray } from 'rxjs'; +import { UserStorageClient } from './user_storage_client'; +import type { UserStorageApi } from './user_storage_api'; + +const apiMock = (): jest.Mocked => + ({ + get: jest.fn().mockReturnValue(new Promise(() => {})), // never resolves by default + set: jest.fn(), + remove: jest.fn(), + } as unknown as jest.Mocked); + +const buildClient = (initialValues: Record = {}) => { + const api = apiMock(); + const done$ = new Subject(); + const client = new UserStorageClient({ api, initialValues, done$ }); + return { api, done$, client }; +}; + +describe('UserStorageClient', () => { + describe('peek', () => { + it('returns cached values without triggering a lazy fetch', () => { + const { client, api } = buildClient({ a: 1 }); + + expect(client.peek('a')).toBe(1); + expect(api.get).not.toHaveBeenCalled(); + }); + + it('returns undefined for a missing key without triggering a lazy fetch', () => { + const { client, api } = buildClient({}); + + expect(client.peek('missing')).toBeUndefined(); + expect(api.get).not.toHaveBeenCalled(); + }); + + it('returns defaultValue for a missing key without triggering a lazy fetch', () => { + const { client, api } = buildClient({}); + + expect(client.peek('missing', 'fallback')).toBe('fallback'); + expect(api.get).not.toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('returns cached values seeded from initialValues', () => { + const { client } = buildClient({ a: 1, b: 'two' }); + + expect(client.get('a')).toBe(1); + expect(client.get('b')).toBe('two'); + }); + + it('returns the defaultValue when the key is not cached', () => { + const { client } = buildClient({}); + + expect(client.get('missing', 'fallback')).toBe('fallback'); + }); + + it('triggers a lazy fetch on first access for an uncached key', () => { + const { client, api } = buildClient({}); + api.get.mockResolvedValue('lazy-value'); + + client.get('uncached'); + + expect(api.get).toHaveBeenCalledWith('uncached'); + }); + + it('does not trigger a second fetch when the key is already cached', () => { + const { client, api } = buildClient({ key: 'present' }); + + client.get('key'); + client.get('key'); + + expect(api.get).not.toHaveBeenCalled(); + }); + + it('does not trigger a second fetch when one is already in flight', () => { + const { client, api } = buildClient({}); + // never resolves — simulates in-flight request + api.get.mockReturnValue(new Promise(() => {})); + + client.get('key'); + client.get('key'); + + expect(api.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('get$', () => { + it('emits the current value immediately and on subsequent updates', async () => { + const { client, api } = buildClient({ key: 'first' }); + api.set.mockResolvedValue('second'); + + const emissions = lastValueFrom(client.get$('key').pipe(take(2), toArray())); + + await client.set('key', 'second'); + + expect(await emissions).toEqual(['first', 'second']); + }); + + it('does not emit for unrelated keys', async () => { + const { client, api } = buildClient({ a: 'initial' }); + api.set.mockResolvedValue(99); + + const first = firstValueFrom(client.get$('a')); + await client.set('b', 99); + + // only the initial emission resolves; if `b` had leaked we'd see 99. + await expect(first).resolves.toBe('initial'); + }); + + it('emits the lazy-fetched value once the fetch resolves', async () => { + const { client, api } = buildClient({}); + + let resolveFetch!: (v: string) => void; + api.get.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))); + + const emissions = lastValueFrom(client.get$('key').pipe(take(2), toArray())); + + // Trigger the lazy fetch (first emission is undefined) + resolveFetch('lazy-value'); + // Allow promise microtasks to flush + await Promise.resolve(); + + expect(await emissions).toEqual([undefined, 'lazy-value']); + }); + + it('emits to getHttpError$ and retries on next subscription if fetch fails', async () => { + const { client, api } = buildClient({}); + + api.get.mockRejectedValueOnce(new Error('network-error')); + + const httpError = firstValueFrom(client.getHttpError$()); + + // First get triggers the fetch + client.get('key'); + + await expect(httpError).resolves.toMatchObject({ message: 'network-error' }); + + // After failure the key is removed from fetchInitiated — a new get should re-trigger + api.get.mockResolvedValue('retry-value'); + client.get('key'); + expect(api.get).toHaveBeenCalledTimes(2); + }); + }); + + describe('set', () => { + it('updates cache and emits on update$ after a successful HTTP call', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.set.mockResolvedValue('new'); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.set('key', 'new'); + + expect(client.get('key')).toBe('new'); + await expect(updates).resolves.toEqual({ + type: 'set', + key: 'key', + newValue: 'new', + oldValue: 'old', + }); + }); + + it('caches the server-validated value rather than the raw input', async () => { + // Simulates a schema that transforms the value (e.g. z.string().trim()). + // The server returns the post-transform value; the browser must cache that. + const { client, api } = buildClient({}); + api.set.mockResolvedValue('trimmed'); + + const stored = await client.set('key', ' trimmed '); + + expect(stored).toBe('trimmed'); + expect(client.get('key')).toBe('trimmed'); + }); + + it('update$ emits the server-validated newValue, not the raw input', async () => { + const { client, api } = buildClient({}); + api.set.mockResolvedValue('normalised'); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.set('key', 'raw input'); + + await expect(updates).resolves.toEqual(expect.objectContaining({ newValue: 'normalised' })); + }); + + it('does not mutate cache or emit when the HTTP call fails, and rejects', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.set.mockRejectedValue(new Error('boom')); + + const errors = firstValueFrom(client.getHttpError$()); + + await expect(client.set('key', 'new')).rejects.toThrow('boom'); + expect(client.get('key')).toBe('old'); + await expect(errors).resolves.toEqual(expect.any(Error)); + }); + }); + + describe('remove', () => { + it('clears cache and emits on update$ after a successful HTTP call', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.remove.mockResolvedValue(undefined); + + const updates = firstValueFrom(client.getUpdate$()); + + await client.remove('key'); + + expect(client.get('key')).toBeUndefined(); + await expect(updates).resolves.toEqual({ type: 'remove', key: 'key', oldValue: 'old' }); + }); + + it('rejects and emits on getHttpError$ when the HTTP call fails', async () => { + const { client, api } = buildClient({ key: 'old' }); + api.remove.mockRejectedValue(new Error('nope')); + + const errors = firstValueFrom(client.getHttpError$()); + + await expect(client.remove('key')).rejects.toThrow('nope'); + expect(client.get('key')).toBe('old'); + await expect(errors).resolves.toEqual(expect.any(Error)); + }); + }); + + describe('done$', () => { + it('completes update$ and getHttpError$ when done$ completes', async () => { + const { client, done$ } = buildClient({}); + + const update$ = client.getUpdate$(); + const errors$ = client.getHttpError$(); + + done$.complete(); + + await expect(lastValueFrom(update$, { defaultValue: 'done' })).resolves.toBe('done'); + await expect(lastValueFrom(errors$, { defaultValue: 'done' })).resolves.toBe('done'); + }); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts new file mode 100644 index 0000000000000..c3015e6e37fdd --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_client.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { cloneDeep } from 'lodash'; +import { Observable, Subject, concat, defer, of } from 'rxjs'; +import { filter, map, share } from 'rxjs'; + +import type { IUserStorageClient, UserStorageUpdate } from '@kbn/core-user-storage-browser'; +import type { UserStorageApi } from './user_storage_api'; + +export interface UserStorageClientParams { + api: UserStorageApi; + initialValues: Record; + done$: Observable; +} + +/** + * Browser-side {@link IUserStorageClient}: a synchronous in-memory cache + * seeded from preloaded (server-injected) metadata (for keys with `preload: true`), + * with HTTP-backed writes and per-key lazy fetching for non-injected keys. + * + * Lazy fetch behaviour: + * - The first `get(key)` / `get$(key)` call for a key that is absent from + * the cache triggers a fire-and-forget `GET /internal/user_storage/{key}` + * request. Once the response arrives, the cache is populated and `get$` + * subscribers for that key receive the resolved value. + * - Fetch failures are published to `getHttpError$` but do not cause `get$` + * to error or complete. The cache entry remains absent. + * - `getUpdate$()` does **not** emit for lazy-fetch hydrations; only explicit + * `set` / `remove` calls produce update events. + * + * @internal + */ +export class UserStorageClient implements IUserStorageClient { + private cache: Record; + private readonly api: UserStorageApi; + private readonly update$ = new Subject(); + private readonly httpErrors$ = new Subject(); + /** Emits whenever the cache is hydrated by a lazy fetch. */ + private readonly loaded$ = new Subject<{ key: string; value: unknown }>(); + /** Set of keys for which a lazy fetch has already been initiated. */ + private readonly fetchInitiated = new Set(); + + constructor({ api, initialValues, done$ }: UserStorageClientParams) { + this.api = api; + this.cache = cloneDeep(initialValues); + + done$.subscribe({ + complete: () => { + this.update$.complete(); + this.httpErrors$.complete(); + this.loaded$.complete(); + }, + }); + } + + public peek(key: string): T | undefined; + public peek(key: string, defaultValue: T): T; + public peek(key: string, defaultValue?: T): T | undefined { + const cached = this.cache[key]; + return cached !== undefined ? (cached as T) : defaultValue; + } + + public get(key: string): T | undefined; + public get(key: string, defaultValue: T): T; + public get(key: string, defaultValue?: T): T | undefined { + const cached = this.cache[key]; + if (cached !== undefined) return cached as T; + this.triggerLazyFetch(key); + return defaultValue; + } + + public get$(key: string): Observable; + public get$(key: string, defaultValue: T): Observable; + public get$(key: string, defaultValue?: T): Observable { + const getCurrent = () => + defaultValue !== undefined ? this.get(key, defaultValue) : this.get(key); + + // The lazy fetch is triggered inside getCurrent() → get() on first eval. + return concat( + defer(() => of(getCurrent())), + // Merge explicit writes and lazy-fetch hydrations for this key. + new Observable((subscriber) => { + const writeSub = this.update$ + .pipe( + filter((u) => u.key === key), + map(() => getCurrent()) + ) + .subscribe(subscriber); + + const loadSub = this.loaded$ + .pipe( + filter((e) => e.key === key), + map(() => + defaultValue !== undefined ? this.get(key, defaultValue) : this.get(key) + ) + ) + .subscribe(subscriber); + + return () => { + writeSub.unsubscribe(); + loadSub.unsubscribe(); + }; + }) + ).pipe(share()); + } + + public async set(key: string, value: T): Promise { + let stored: T; + try { + // Cache the server-validated value (post-transform/strip) rather than + // the raw input, so the browser state stays in sync with what ES holds. + stored = (await this.api.set(key, value)) as T; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.httpErrors$.next(err); + throw err; + } + + const oldValue = this.cache[key]; + this.cache[key] = stored; + this.update$.next({ type: 'set', key, newValue: stored, oldValue }); + return stored; + } + + public async remove(key: string): Promise { + try { + await this.api.remove(key); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.httpErrors$.next(err); + throw err; + } + + const oldValue = this.cache[key]; + delete this.cache[key]; + this.update$.next({ type: 'remove', key, oldValue }); + } + + public getUpdate$(): Observable { + return this.update$.asObservable(); + } + + public getHttpError$(): Observable { + return this.httpErrors$.asObservable(); + } + + /** + * Initiates a single fire-and-forget GET for `key` if it is not yet cached + * and no prior fetch has been triggered for it in the lifetime of this client. + */ + private triggerLazyFetch(key: string): void { + if (this.cache[key] !== undefined || this.fetchInitiated.has(key)) return; + this.fetchInitiated.add(key); + + this.api.get(key).then( + (value) => { + this.cache[key] = value; + this.loaded$.next({ key, value }); + }, + (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + this.httpErrors$.next(err); + // Remove key from the initiated set so callers can retry on + // re-mount if they want to (same session, same client instance). + this.fetchInitiated.delete(key); + } + ); + } +} diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts new file mode 100644 index 0000000000000..25dda0174048f --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { firstValueFrom, lastValueFrom } from 'rxjs'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { injectedMetadataServiceMock } from '@kbn/core-injected-metadata-browser-mocks'; +import { UserStorageService } from './user_storage_service'; + +const buildDeps = (initialValues: Record = {}) => { + const http = httpServiceMock.createSetupContract(); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getUserStorage.mockReturnValue({ values: initialValues }); + return { http, injectedMetadata }; +}; + +describe('UserStorageService', () => { + it('seeds the client cache from injected metadata at setup', () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps({ key: 42 })); + + expect(client.get('key')).toBe(42); + }); + + it('returns the same client from start as setup', () => { + const service = new UserStorageService(); + const setupClient = service.setup(buildDeps()); + + expect(service.start()).toBe(setupClient); + }); + + it('writes through to http.put on set', async () => { + const service = new UserStorageService(); + const deps = buildDeps(); + deps.http.put.mockResolvedValue({ value: { hidden: ['discover'] } }); + + const client = service.setup(deps); + + await client.set('navigation:layout', { hidden: ['discover'] }); + + expect(deps.http.put).toHaveBeenCalledWith( + '/internal/user_storage/navigation%3Alayout', + expect.objectContaining({ + body: JSON.stringify({ value: { hidden: ['discover'] } }), + }) + ); + expect(client.get('navigation:layout')).toEqual({ hidden: ['discover'] }); + }); + + it('completes the client observables on stop', async () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps()); + + const update$ = client.getUpdate$(); + const errors$ = client.getHttpError$(); + + service.stop(); + + await expect(lastValueFrom(update$, { defaultValue: 'done' })).resolves.toBe('done'); + await expect(lastValueFrom(errors$, { defaultValue: 'done' })).resolves.toBe('done'); + }); + + it('emits the seeded value as the first observable emission', async () => { + const service = new UserStorageService(); + const client = service.setup(buildDeps({ key: 'seed' })); + + await expect(firstValueFrom(client.get$('key'))).resolves.toBe('seed'); + }); +}); diff --git a/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts b/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts new file mode 100644 index 0000000000000..bcdf42fc0ff31 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/src/user_storage_service.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject } from 'rxjs'; + +import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal'; +import type { InternalHttpSetup } from '@kbn/core-http-browser-internal'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +import { UserStorageApi } from './user_storage_api'; +import { UserStorageClient } from './user_storage_client'; + +export interface UserStorageServiceDeps { + http: InternalHttpSetup; + injectedMetadata: InternalInjectedMetadataSetup; +} + +/** + * Browser core service that owns the lifecycle of the {@link IUserStorageClient}. + * + * @internal + */ +export class UserStorageService { + private client?: UserStorageClient; + private readonly done$ = new Subject(); + + public setup({ http, injectedMetadata }: UserStorageServiceDeps): IUserStorageClient { + const api = new UserStorageApi(http); + const initialValues = injectedMetadata.getUserStorage().values; + + this.client = new UserStorageClient({ + api, + initialValues, + done$: this.done$, + }); + + return this.client; + } + + public start(): IUserStorageClient { + return this.client!; + } + + public stop() { + this.done$.next(); + this.done$.complete(); + } +} diff --git a/src/core/packages/user-storage/browser-internal/tsconfig.json b/src/core/packages/user-storage/browser-internal/tsconfig.json new file mode 100644 index 0000000000000..ef97e584b55b3 --- /dev/null +++ b/src/core/packages/user-storage/browser-internal/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "kbn_references": [ + "@kbn/core-http-browser-internal", + "@kbn/core-http-browser-mocks", + "@kbn/core-injected-metadata-browser-internal", + "@kbn/core-injected-metadata-browser-mocks", + "@kbn/core-user-storage-browser" + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/src/core/packages/user-storage/browser-mocks/index.ts b/src/core/packages/user-storage/browser-mocks/index.ts new file mode 100644 index 0000000000000..8d2cfaefa423a --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { userStorageServiceMock } from './src/user_storage_service.mock'; diff --git a/src/core/packages/user-storage/browser-mocks/jest.config.js b/src/core/packages/user-storage/browser-mocks/jest.config.js new file mode 100644 index 0000000000000..327e5df96bf9e --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser-mocks'], +}; diff --git a/src/core/packages/user-storage/browser-mocks/kibana.jsonc b/src/core/packages/user-storage/browser-mocks/kibana.jsonc new file mode 100644 index 0000000000000..508212d640841 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/kibana.jsonc @@ -0,0 +1,10 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser-mocks", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared", + "devOnly": true +} diff --git a/src/core/packages/user-storage/browser-mocks/moon.yml b/src/core/packages/user-storage/browser-mocks/moon.yml new file mode 100644 index 0000000000000..799504a1fe5ce --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/moon.yml @@ -0,0 +1,35 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser-mocks' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser-mocks' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser-mocks' + description: Moon project for @kbn/core-user-storage-browser-mocks + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser-mocks +dependsOn: + - '@kbn/core-user-storage-browser' + - '@kbn/lazy-object' +tags: + - shared-browser + - package + - dev + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser-mocks/package.json b/src/core/packages/user-storage/browser-mocks/package.json new file mode 100644 index 0000000000000..4d2e67e693494 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser-mocks", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser-mocks/src/client.mock.ts b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts new file mode 100644 index 0000000000000..0b1e4c75f3d59 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/src/client.mock.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Subject } from 'rxjs'; +import { lazyObject } from '@kbn/lazy-object'; +import type { IUserStorageClient } from '@kbn/core-user-storage-browser'; + +export const clientMock = (): jest.Mocked => + lazyObject({ + peek: jest.fn(), + get: jest.fn(), + get$: jest.fn().mockReturnValue(new Subject()), + set: jest.fn().mockImplementation((_key: string, value: unknown) => Promise.resolve(value)), + remove: jest.fn().mockResolvedValue(undefined), + getUpdate$: jest.fn().mockReturnValue(new Subject()), + getHttpError$: jest.fn().mockReturnValue(new Subject()), + }); diff --git a/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts new file mode 100644 index 0000000000000..ea837fc778914 --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/src/user_storage_service.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { clientMock } from './client.mock'; + +export const userStorageServiceMock = { + createSetupContract: () => clientMock(), + createStartContract: () => clientMock(), +}; diff --git a/src/core/packages/user-storage/browser-mocks/tsconfig.json b/src/core/packages/user-storage/browser-mocks/tsconfig.json new file mode 100644 index 0000000000000..6e710f25f3fcb --- /dev/null +++ b/src/core/packages/user-storage/browser-mocks/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "kbn_references": [ + "@kbn/core-user-storage-browser", + "@kbn/lazy-object" + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/src/core/packages/user-storage/browser/index.ts b/src/core/packages/user-storage/browser/index.ts new file mode 100644 index 0000000000000..abb8725c0133f --- /dev/null +++ b/src/core/packages/user-storage/browser/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + IUserStorageClient, + UserStorageUpdate, + UserStorageProviderProps, + UserStorageSetter, +} from './src'; +export { UserStorageProvider, useUserStorage, useUserStorageClient } from './src'; diff --git a/src/core/packages/user-storage/browser/jest.config.js b/src/core/packages/user-storage/browser/jest.config.js new file mode 100644 index 0000000000000..9cfd18b0c25b3 --- /dev/null +++ b/src/core/packages/user-storage/browser/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/user-storage/browser'], +}; diff --git a/src/core/packages/user-storage/browser/kibana.jsonc b/src/core/packages/user-storage/browser/kibana.jsonc new file mode 100644 index 0000000000000..d90f0e351709d --- /dev/null +++ b/src/core/packages/user-storage/browser/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-user-storage-browser", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/core/packages/user-storage/browser/moon.yml b/src/core/packages/user-storage/browser/moon.yml new file mode 100644 index 0000000000000..e41b65294266e --- /dev/null +++ b/src/core/packages/user-storage/browser/moon.yml @@ -0,0 +1,34 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-user-storage-browser' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-user-storage-browser' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-user-storage-browser' + description: Moon project for @kbn/core-user-storage-browser + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/user-storage/browser +dependsOn: [] +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} diff --git a/src/core/packages/user-storage/browser/package.json b/src/core/packages/user-storage/browser/package.json new file mode 100644 index 0000000000000..394f22357c4c8 --- /dev/null +++ b/src/core/packages/user-storage/browser/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-user-storage-browser", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/core/packages/user-storage/browser/src/index.ts b/src/core/packages/user-storage/browser/src/index.ts new file mode 100644 index 0000000000000..1375e9503b600 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { IUserStorageClient, UserStorageUpdate } from './types'; +export { UserStorageProvider } from './user_storage_provider'; +export type { UserStorageProviderProps } from './user_storage_provider'; +export { useUserStorage, useUserStorageClient } from './use_user_storage'; +export type { UserStorageSetter } from './use_user_storage'; diff --git a/src/core/packages/user-storage/browser/src/types.ts b/src/core/packages/user-storage/browser/src/types.ts new file mode 100644 index 0000000000000..b712bd3eb51a5 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/types.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Observable } from 'rxjs'; + +/** + * An update emission published when a stored value changes. + * + * Use the `type` discriminant to distinguish a value write (`'set'`) from a + * user-override removal (`'remove'`). The `'remove'` variant has no `newValue` + * because the effective value reverts to the registered default — callers + * should read the post-removal state via `get()` if needed. + * + * @public + */ +export type UserStorageUpdate = + | { type: 'set'; key: string; newValue: T; oldValue: T | undefined } + | { type: 'remove'; key: string; oldValue: T | undefined }; + +/** + * Browser-side user storage client. Returns synchronously from an in-memory + * cache that is seeded from preloaded (server-injected) metadata at first + * paint, and is refreshed by `set` / `remove` after the corresponding HTTP + * write completes. + * + * Distinct from the server-side `IUserStorageClient` (in + * `@kbn/core-user-storage-common`) which is fully Promise-based. + * + * @public + */ +export interface IUserStorageClient { + /** + * Pure synchronous read from the local cache with no side effects. + * Returns `undefined` when no cached value exists for the key and no + * `defaultValue` is provided. + * + * Unlike `get`, `peek` never triggers a lazy fetch, making it safe to + * call during React render (which may be invoked multiple times before + * a commit under concurrent mode). + */ + peek(key: string): T | undefined; + peek(key: string, defaultValue: T): T; + + /** + * Synchronous read from the local cache. Returns `undefined` when no cached + * value exists for the key and no `defaultValue` is provided. + * + * For keys without `preload: true`, the first call for an uncached key + * triggers a fire-and-forget lazy HTTP fetch in the background. Prefer + * `peek` in render functions; use `get` in imperative / effect code where + * triggering the fetch on first access is the intended behaviour. + */ + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + + /** + * Observable that emits the current cached value followed by every future + * value seen for the given key. Emits `undefined` when no cached value + * exists and no `defaultValue` is provided. Suitable for React subscriptions. + */ + get$(key: string): Observable; + get$(key: string, defaultValue: T): Observable; + + /** + * Persists a new value via `PUT /internal/user_storage/{key}`. Returns the + * server-validated form of the value (after any Zod transforms or stripping), + * which is also what gets cached locally. On HTTP failure the cache is left + * untouched, the error is published to `getHttpError$`, and the promise rejects. + */ + set(key: string, value: T): Promise; + + /** + * Removes the user override via `DELETE /internal/user_storage/{key}`. + * On success the cached value is deleted (subsequent reads fall back to + * `defaultValue`) and subscribers are notified. + */ + remove(key: string): Promise; + + /** + * Stream of every successful key update (write or remove). + * Does **not** emit for lazy-fetch cache hydrations. + */ + getUpdate$(): Observable; + + /** + * Stream of HTTP errors raised by `set`, `remove`, or lazy-fetch calls. + * Suitable for centralised toast / telemetry handling. + */ + getHttpError$(): Observable; +} diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx new file mode 100644 index 0000000000000..1690eb7abf16a --- /dev/null +++ b/src/core/packages/user-storage/browser/src/use_user_storage.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import type { IUserStorageClient } from './types'; +import { UserStorageProvider } from './user_storage_provider'; +import { useUserStorage, useUserStorageClient } from './use_user_storage'; + +const buildClient = (initial: Record = {}): IUserStorageClient => { + const cache: Record = { ...initial }; + const subject$ = new BehaviorSubject>(cache); + const client: IUserStorageClient = { + peek: ((key: string, defaultValue?: unknown) => + (cache[key] !== undefined + ? cache[key] + : defaultValue) as never) as IUserStorageClient['peek'], + get: ((key: string, defaultValue?: unknown) => + (cache[key] !== undefined ? cache[key] : defaultValue) as never) as IUserStorageClient['get'], + get$: ((key: string, defaultValue?: unknown) => { + // simple "get current value" observable + return new BehaviorSubject( + cache[key] !== undefined ? cache[key] : defaultValue + ).asObservable(); + }) as IUserStorageClient['get$'], + set: jest.fn(async (key: string, value: unknown) => { + cache[key] = value; + subject$.next({ ...cache }); + return value; + }) as IUserStorageClient['set'], + remove: jest.fn(async (key: string) => { + delete cache[key]; + subject$.next({ ...cache }); + }) as IUserStorageClient['remove'], + getUpdate$: () => + new BehaviorSubject({ type: 'remove' as const, key: '', oldValue: undefined }), + getHttpError$: () => new BehaviorSubject(new Error('noop')), + }; + return client; +}; + +const wrapper = + (client: IUserStorageClient) => + ({ children }: { children: React.ReactNode }) => + {children}; + +// React surfaces render-time errors via console.error; suppress to keep +// expected-throw tests from polluting test output. +const expectThrowsFromRender = (fn: () => unknown, pattern: RegExp) => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(fn).toThrow(pattern); + } finally { + spy.mockRestore(); + } +}; + +describe('useUserStorageClient', () => { + it('throws a clear error when called outside ', () => { + expectThrowsFromRender( + () => renderHook(() => useUserStorageClient()), + /must be used inside a / + ); + }); + + it('returns the provided client', () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorageClient(), { wrapper: wrapper(client) }); + + expect(result.current).toBe(client); + }); +}); + +describe('useUserStorage', () => { + it('throws when called outside ', () => { + expectThrowsFromRender( + () => renderHook(() => useUserStorage('any')), + /must be used inside a / + ); + }); + + it('returns the cached value as the initial render', () => { + const client = buildClient({ 'navigation:layout': { hidden: ['discover'] } }); + + const { result } = renderHook(() => useUserStorage<{ hidden: string[] }>('navigation:layout'), { + wrapper: wrapper(client), + }); + + const [value] = result.current; + expect(value).toEqual({ hidden: ['discover'] }); + }); + + it('falls back to defaultValue when the key is missing', () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorage('missing', 'fallback'), { + wrapper: wrapper(client), + }); + + const [value] = result.current; + expect(value).toBe('fallback'); + }); + + it('calls client.set when the setter is invoked', async () => { + const client = buildClient(); + + const { result } = renderHook(() => useUserStorage('counter', 0), { + wrapper: wrapper(client), + }); + + await act(async () => { + await result.current[1](7); + }); + + expect(client.set).toHaveBeenCalledWith('counter', 7); + }); +}); diff --git a/src/core/packages/user-storage/browser/src/use_user_storage.ts b/src/core/packages/user-storage/browser/src/use_user_storage.ts new file mode 100644 index 0000000000000..94b0f87cf158b --- /dev/null +++ b/src/core/packages/user-storage/browser/src/use_user_storage.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback, useContext, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; +import type { IUserStorageClient } from './types'; +import { UserStorageContext } from './user_storage_context'; + +const PROVIDER_MISSING_MESSAGE = + 'useUserStorage / useUserStorageClient must be used inside a . ' + + 'Wrap your component tree in .'; + +/** + * Returns the {@link IUserStorageClient} provided by the nearest + * {@link UserStorageProvider}. Throws if no provider is mounted in the tree. + * + * @public + */ +export const useUserStorageClient = (): IUserStorageClient => { + const client = useContext(UserStorageContext); + if (!client) { + throw new Error(PROVIDER_MISSING_MESSAGE); + } + return client; +}; + +export type UserStorageSetter = (newValue: T) => Promise; + +/** + * Subscribes to a single user-storage key and returns a `[value, setter]` + * tuple. The value reflects the synchronous cache and re-renders on change. + * The setter persists via HTTP and updates the cache on success. + * + * When called without a `defaultValue` the first element of the tuple is + * `T | undefined` — it is `undefined` when the key has no cached value. + * When called with a `defaultValue` it is always `T`. + * + * @example + * ```tsx + * const [layout, setLayout] = useUserStorage( + * 'navigation:layout', + * defaultLayout + * ); + * ``` + * + * @public + */ +export function useUserStorage(key: string): [T | undefined, UserStorageSetter]; +export function useUserStorage( + key: string, + defaultValue: T +): [T, UserStorageSetter]; +export function useUserStorage( + key: string, + defaultValue?: T +): [T | undefined, UserStorageSetter] { + const client = useUserStorageClient(); + + const observable$: Observable = useMemo( + () => (defaultValue !== undefined ? client.get$(key, defaultValue) : client.get$(key)), + [client, key, defaultValue] + ); + // Use peek (pure, no side effects) for the synchronous initial render value. + // The lazy fetch is triggered by the get$() subscription inside useObservable, + // which runs inside an effect after the first commit — safe under concurrent mode. + const value = useObservable( + observable$, + defaultValue !== undefined ? client.peek(key, defaultValue) : client.peek(key) + ); + const set = useCallback>( + (newValue) => client.set(key, newValue), + [client, key] + ); + + return [value, set]; +} diff --git a/src/core/packages/user-storage/browser/src/user_storage_context.ts b/src/core/packages/user-storage/browser/src/user_storage_context.ts new file mode 100644 index 0000000000000..2a8a227bbb903 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/user_storage_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createContext } from 'react'; +import type { IUserStorageClient } from './types'; + +export const UserStorageContext = createContext(null); diff --git a/src/core/packages/user-storage/browser/src/user_storage_provider.tsx b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx new file mode 100644 index 0000000000000..492362b21e0f4 --- /dev/null +++ b/src/core/packages/user-storage/browser/src/user_storage_provider.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { type ReactNode } from 'react'; +import type { IUserStorageClient } from './types'; +import { UserStorageContext } from './user_storage_context'; + +export interface UserStorageProviderProps { + userStorage: IUserStorageClient; + children: ReactNode; +} + +/** + * Provider that exposes a {@link IUserStorageClient} to descendant components + * via React context. Required for `useUserStorage` and `useUserStorageClient`. + * + * @public + */ +export const UserStorageProvider = ({ userStorage, children }: UserStorageProviderProps) => ( + {children} +); diff --git a/src/core/packages/user-storage/browser/tsconfig.json b/src/core/packages/user-storage/browser/tsconfig.json new file mode 100644 index 0000000000000..b794c1ced41c9 --- /dev/null +++ b/src/core/packages/user-storage/browser/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "kbn_references": [ + ], + "exclude": [ + "target/**/*" + ] +} diff --git a/src/core/packages/user-storage/common/src/types.ts b/src/core/packages/user-storage/common/src/types.ts index 0ba7a0c4ae17e..42d986aafb363 100644 --- a/src/core/packages/user-storage/common/src/types.ts +++ b/src/core/packages/user-storage/common/src/types.ts @@ -20,6 +20,20 @@ export interface UserStorageDefinition { defaultValue: T; /** Whether this key is per-space or global. */ scope: UserStorageScope; + /** + * When `true`, the effective value for this key is resolved server-side at + * first-paint time and embedded in the page HTML so the browser cache is + * pre-populated before any JavaScript runs. + * + * Keys without `preload: true` are loaded lazily: the browser cache + * starts empty for that key and the first `get(key)` / `get$(key)` call + * triggers a per-key HTTP fetch to hydrate the cache. + * + * Prefer `preload: true` only for keys whose values are needed on the + * critical rendering path. Large or rarely-read payloads should remain lazy + * to avoid bloating the initial HTML payload. + */ + preload?: boolean; } /** A record of key → definition, passed to `register()`. */ @@ -29,10 +43,18 @@ export type UserStorageRegistrations = Record; export interface IUserStorageClient { /** Resolve a single key: returns the user override or the registered default. */ get(key: string): Promise; - /** Resolve all registered keys (user overrides merged with defaults). */ - getAll(): Promise>; - /** Validate and persist a value for the current user. */ - set(key: string, value: T): Promise; + /** + * Resolve all keys whose definition has `preload: true`, merging user + * overrides with registered defaults. Used exclusively by the rendering + * service to embed values in the initial HTML payload. + */ + getForInjection(): Promise>; + /** + * Validate and persist a value for the current user. Returns the + * schema-validated form of the value as stored (after any Zod transforms + * or stripping), so callers can use the canonical representation. + */ + set(key: string, value: T): Promise; /** Remove the user override so the key falls back to its default. */ remove(key: string): Promise; } diff --git a/src/core/packages/user-storage/server-internal/src/routes/index.ts b/src/core/packages/user-storage/server-internal/src/routes/index.ts index 819fdc4141b18..ba0b9c0ed25e8 100644 --- a/src/core/packages/user-storage/server-internal/src/routes/index.ts +++ b/src/core/packages/user-storage/server-internal/src/routes/index.ts @@ -52,19 +52,30 @@ const createClientOrNull = ( export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesParams) => { router.get( { - path: '/internal/user_storage', - validate: false, + path: '/internal/user_storage/{key}', + validate: { + params: z.object({ key: z.string() }), + }, security: { authz: AuthzDisabled.delegateToSOClient, }, }, - async (requestHandlerContext, _request, response) => { + async (requestHandlerContext, request, response) => { const coreCtx = await requestHandlerContext.core; const client = createClientOrNull(coreCtx, { definitions, logger }); if (!client) return response.forbidden({ body: { message: FORBIDDEN_MESSAGE } }); - const values = await client.getAll(); - return response.ok({ body: values }); + const { key } = request.params; + + try { + const value = await client.get(key); + return response.ok({ body: { value } }); + } catch (err) { + if (isUnregisteredKeyError(err)) { + return response.badRequest({ body: { message: err.message } }); + } + throw err; + } } ); @@ -87,8 +98,9 @@ export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesPa const { key } = request.params; const { value } = request.body; + let validated: unknown; try { - await client.set(key, value); + validated = await client.set(key, value); } catch (err) { if (err instanceof z.ZodError) { return response.badRequest({ body: { message: `Validation failed: ${err.message}` } }); @@ -99,7 +111,7 @@ export const registerRoutes = ({ router, definitions, logger }: RegisterRoutesPa throw err; } - return response.ok({ body: {} }); + return response.ok({ body: { value: validated } }); } ); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts index 4042c17e06544..fd8560375b56c 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.test.ts @@ -28,11 +28,46 @@ const buildClient = (definitions: Map) => { return { client, savedObjectsClient, logger }; }; -describe('UserStorageClient.getAll()', () => { - it('issues a single bulkGet covering both SO types when both scopes are registered', async () => { +describe('UserStorageClient.getForInjection()', () => { + it('returns empty object when no definitions have preload: true', async () => { const definitions = new Map([ ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], - ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + + const result = await client.getForInjection(); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('only includes keys with preload: true, skipping non-injectable keys', async () => { + const definitions = new Map([ + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['space:b', { schema: z.string(), defaultValue: 'b-default', scope: 'space' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: PROFILE_UID, + type: USER_STORAGE_SO_TYPE, + references: [], + attributes: { userId: PROFILE_UID, data: { 'space:a': 'hello', 'space:b': 'world' } }, + }, + ] as any, + }); + + const result = await client.getForInjection(); + + expect(result).toEqual({ 'space:a': 'hello' }); + }); + + it('issues a single bulkGet covering both SO types when injectable keys span both scopes', async () => { + const definitions = new Map([ + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -53,7 +88,7 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([ @@ -64,9 +99,10 @@ describe('UserStorageClient.getAll()', () => { expect(result).toEqual({ 'space:a': 'hello', 'global:b': 7 }); }); - it('only requests the registered scope when one scope is unused', async () => { + it('only requests the scope(s) used by injectable keys', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['global:b', { schema: z.number(), defaultValue: 0, scope: 'global' }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -81,28 +117,17 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - await client.getAll(); + await client.getForInjection(); - expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([ { type: USER_STORAGE_SO_TYPE, id: PROFILE_UID }, ]); }); - it('skips the SO request entirely when no definitions are registered', async () => { - const { client, savedObjectsClient } = buildClient(new Map()); - - const result = await client.getAll(); - - expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); - expect(result).toEqual({}); - }); - it('returns defaults when bulkGet reports a missing doc via the error field', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], - ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global' }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], + ['global:b', { schema: z.number(), defaultValue: 42, scope: 'global', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); @@ -128,14 +153,14 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(result).toEqual({ 'space:a': 'a-default', 'global:b': 99 }); }); it('falls back to defaults and warns when a stored value fails schema validation', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ]); const { client, savedObjectsClient, logger } = buildClient(definitions); @@ -150,7 +175,7 @@ describe('UserStorageClient.getAll()', () => { ] as any, }); - const result = await client.getAll(); + const result = await client.getForInjection(); expect(result).toEqual({ 'space:a': 'a-default' }); expect(logger.warn).toHaveBeenCalledTimes(1); @@ -159,12 +184,26 @@ describe('UserStorageClient.getAll()', () => { it('propagates errors thrown by bulkGet itself (e.g. transport failure)', async () => { const definitions = new Map([ - ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space' }], + ['space:a', { schema: z.string(), defaultValue: 'a-default', scope: 'space', preload: true }], ]); const { client, savedObjectsClient } = buildClient(definitions); savedObjectsClient.bulkGet.mockRejectedValue(new Error('boom')); - await expect(client.getAll()).rejects.toThrow('boom'); + await expect(client.getForInjection()).rejects.toThrow('boom'); + }); +}); + +describe('UserStorageClient.set()', () => { + it('returns the schema-validated value', async () => { + const definitions = new Map([ + ['space:a', { schema: z.string().trim(), defaultValue: '', scope: 'space' }], + ]); + const { client, savedObjectsClient } = buildClient(definitions); + savedObjectsClient.update.mockResolvedValue({} as any); + + const result = await client.set('space:a', ' hello '); + + expect(result).toBe('hello'); }); }); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts index cd8f6a3949615..e67096612aec6 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_client.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_client.ts @@ -64,10 +64,13 @@ export class UserStorageClient implements IUserStorageClient { return definition.defaultValue as T; } - async getAll(): Promise> { + async getForInjection(): Promise> { + const injectableEntries = [...this.definitions.entries()].filter(([, d]) => d.preload === true); + if (injectableEntries.length === 0) return {}; + let hasSpace = false; let hasGlobal = false; - for (const d of this.definitions.values()) { + for (const [, d] of injectableEntries) { if (d.scope === 'space') hasSpace = true; else if (d.scope === 'global') hasGlobal = true; if (hasSpace && hasGlobal) break; @@ -96,7 +99,7 @@ export class UserStorageClient implements IUserStorageClient { } const result: Record = {}; - for (const [key, definition] of this.definitions) { + for (const [key, definition] of injectableEntries) { const data = definition.scope === 'space' ? spaceData : globalData; const raw = data?.[key]; if (raw != null) { @@ -114,9 +117,9 @@ export class UserStorageClient implements IUserStorageClient { return result; } - async set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { const definition = this.assertRegistered(key); - const validated = definition.schema.parse(value); + const validated = definition.schema.parse(value) as T; const soType = this.getSoType(definition); this.logger.debug(`Setting userStorage key [${key}] (scope: ${definition.scope})`); @@ -146,6 +149,8 @@ export class UserStorageClient implements IUserStorageClient { throw err; } } + + return validated; } async remove(key: string): Promise { diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts new file mode 100644 index 0000000000000..7981859e590c9 --- /dev/null +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import { loggerMock } from '@kbn/logging-mocks'; +import type { CoreContext } from '@kbn/core-base-server-internal'; +import { UserStorageService } from './user_storage_service'; + +jest.mock('./routes', () => ({ registerRoutes: jest.fn() })); + +const buildRegister = () => { + const coreContext = { + logger: { get: () => loggerMock.create() }, + } as unknown as CoreContext; + + const service = new UserStorageService(coreContext); + const { register } = service.setup({ + http: { + createRouter: jest.fn().mockReturnValue({ + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }), + }, + savedObjects: { registerType: jest.fn() }, + } as any); + + return register; +}; + +describe('UserStorageService.register()', () => { + it('accepts a valid schema with a matching defaultValue', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'hello', scope: 'space' } }) + ).not.toThrow(); + }); + + it('throws when the defaultValue does not match the schema', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 42 as any, scope: 'space' } }) + ).toThrow(/does not match its schema/); + }); + + it('throws when the same key is registered twice', () => { + const register = buildRegister(); + register({ 'test:key': { schema: z.string(), defaultValue: 'x', scope: 'space' } }); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'y', scope: 'space' } }) + ).toThrow(/already been registered/); + }); + + it('throws when the schema accepts null (reserved tombstone value)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.string().nullable(), defaultValue: null as any, scope: 'space' }, + }) + ).toThrow(/must not accept null/); + }); + + it('throws when a z.null() schema is registered', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.null(), defaultValue: null as any, scope: 'space' } }) + ).toThrow(/must not accept null/); + }); + + it('accepts a schema that explicitly rejects null (z.string() is non-nullable by default)', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: '', scope: 'global' } }) + ).not.toThrow(); + }); + + it('throws when the schema accepts undefined (reserved for uncached reads)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { + schema: z.string().optional(), + defaultValue: 'fallback', + scope: 'space', + }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('throws when a z.undefined() schema is registered', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.undefined(), defaultValue: undefined as any, scope: 'space' }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('throws when a z.unknown() schema is registered (accepts undefined)', () => { + const register = buildRegister(); + expect(() => + register({ + 'test:key': { schema: z.unknown(), defaultValue: 0 as any, scope: 'space' }, + }) + ).toThrow(/must not accept undefined/); + }); + + it('accepts a schema that explicitly rejects undefined (z.string() is required by default)', () => { + const register = buildRegister(); + expect(() => + register({ 'test:key': { schema: z.string(), defaultValue: 'x', scope: 'global' } }) + ).not.toThrow(); + }); +}); diff --git a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts index a8a6b748b7ac1..1915e4f3c24e3 100644 --- a/src/core/packages/user-storage/server-internal/src/user_storage_service.ts +++ b/src/core/packages/user-storage/server-internal/src/user_storage_service.ts @@ -74,6 +74,19 @@ export class UserStorageService { `userStorage key [${key}] has a defaultValue that does not match its schema: ${message}` ); } + if (definition.schema.safeParse(undefined).success) { + throw new Error( + `userStorage key [${key}] schema must not accept undefined. ` + + `undefined is reserved for absent cache entries and JSON.stringify omits ` + + `undefined properties, so it cannot be a reliable stored value.` + ); + } + if (definition.schema.safeParse(null).success) { + throw new Error( + `userStorage key [${key}] schema must not accept null. ` + + `null is reserved as the removal tombstone in the underlying storage layer.` + ); + } this.definitions.set(key, definition); } }, diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts index 5801794b742f6..58464b246afdd 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/crud.spec.ts @@ -27,23 +27,25 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () }); apiTest('GET returns all registered keys with their default values', async ({ apiClient }) => { - const response = await h.get(apiClient); - expect(response).toHaveStatusCode(200); + const response = await h.getAllKeys(apiClient); + expect(response.statusCode).toBe(200); expect(response.body).toStrictEqual(DEFAULT_VALUES); }); apiTest('PUT sets a valid string value', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:string_key', 'custom_value')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('custom_value'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('custom_value'); }); apiTest('PUT sets a valid number value', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', 99)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(99); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(99); }); apiTest('PUT returns 400 for an unregistered key', async ({ apiClient }) => { @@ -57,8 +59,9 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () expect(await h.del(apiClient, 'test:string_key')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('default_value'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('default_value'); }); apiTest('DELETE returns 400 for an unregistered key', async ({ apiClient }) => { @@ -75,30 +78,34 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () }; expect(await h.put(apiClient, 'test:object_key', value)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:object_key']).toStrictEqual(value); + const response = await h.getKey(apiClient, 'test:object_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual(value); }); apiTest('PUT/GET array round-trips correctly', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:array_key', ['a', 'b', 'c'])).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:array_key']).toStrictEqual(['a', 'b', 'c']); + const response = await h.getKey(apiClient, 'test:array_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual(['a', 'b', 'c']); }); apiTest('PUT/GET boolean round-trips correctly', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:boolean_key', true)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:boolean_key']).toBe(true); + const response = await h.getKey(apiClient, 'test:boolean_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(true); }); apiTest('Multiple PUTs overwrite — last value wins', async ({ apiClient }) => { await h.put(apiClient, 'test:string_key', 'first'); await h.put(apiClient, 'test:string_key', 'second'); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe('second'); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe('second'); }); apiTest( @@ -112,8 +119,8 @@ apiTest.describe('User Storage - CRUD', { tag: [...tags.stateful.classic] }, () pinnedItems: ['item-1'], }); - const response = await h.get(apiClient); - expect(response).toHaveStatusCode(200); + const response = await h.getAllKeys(apiClient); + expect(response.statusCode).toBe(200); expect(response.body).toMatchObject({ 'test:string_key': 'custom', 'test:number_key': 100, diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts index f3fb9e05e566f..f131ed4b99cca 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/forbidden.spec.ts @@ -24,7 +24,7 @@ apiTest.describe( }); apiTest('GET returns 403 with API key auth', async ({ apiClient }) => { - const response = await h.get(apiClient); + const response = await h.getKey(apiClient, 'test:string_key'); expect(response).toHaveStatusCode(403); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts index b16dfc641a168..900435c478bcc 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/helpers.ts @@ -9,6 +9,9 @@ import { INTERNAL_HEADERS } from '../fixtures'; +// Mirrors the `register()` call in +// `src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts`. +// Keep both in sync when changing the test fixture. export const ALL_KEYS = [ 'test:string_key', 'test:number_key', @@ -51,7 +54,64 @@ interface ApiClient { export const createHelpers = (headersGetter: () => Record) => { const headers = () => ({ ...INTERNAL_HEADERS, ...headersGetter() }); + const getKey = (apiClient: ApiClient, key: string) => + apiClient.get(`internal/user_storage/${key}`, { + headers: headers(), + responseType: 'json', + }); + + const getKeyInSpace = (apiClient: ApiClient, spaceId: string, key: string) => + apiClient.get(`s/${spaceId}/internal/user_storage/${key}`, { + headers: headers(), + responseType: 'json', + }); + + /** + * Fetches every key in ALL_KEYS individually and assembles the results into + * a response-like object: `{ statusCode, body: Record }`. + * The `statusCode` reflects the last non-200 response seen, or 200 if all + * succeeded. Use this when a test needs to assert across multiple keys at + * once while staying compatible with the per-key route. + */ + const getAllKeys = async ( + apiClient: ApiClient + ): Promise<{ statusCode: number; body: Record }> => { + const body: Record = {}; + let lastFailCode = 200; + for (const key of ALL_KEYS) { + const res = await getKey(apiClient, key); + if (res.statusCode !== 200) { + lastFailCode = res.statusCode; + continue; + } + body[key] = res.body.value; + } + return { statusCode: lastFailCode, body }; + }; + + const getAllKeysInSpace = async ( + apiClient: ApiClient, + spaceId: string + ): Promise<{ statusCode: number; body: Record }> => { + const body: Record = {}; + let lastFailCode = 200; + for (const key of ALL_KEYS) { + const res = await getKeyInSpace(apiClient, spaceId, key); + if (res.statusCode !== 200) { + lastFailCode = res.statusCode; + continue; + } + body[key] = res.body.value; + } + return { statusCode: lastFailCode, body }; + }; + return { + getKey, + getKeyInSpace, + getAllKeys, + getAllKeysInSpace, + put: (apiClient: ApiClient, key: string, value: unknown) => apiClient.put(`internal/user_storage/${key}`, { headers: headers(), @@ -59,12 +119,6 @@ export const createHelpers = (headersGetter: () => Record) => { responseType: 'json', }), - get: (apiClient: ApiClient) => - apiClient.get('internal/user_storage', { - headers: headers(), - responseType: 'json', - }), - del: (apiClient: ApiClient, key: string) => apiClient.delete(`internal/user_storage/${key}`, { headers: headers(), @@ -78,12 +132,6 @@ export const createHelpers = (headersGetter: () => Record) => { responseType: 'json', }), - getInSpace: (apiClient: ApiClient, spaceId: string) => - apiClient.get(`s/${spaceId}/internal/user_storage`, { - headers: headers(), - responseType: 'json', - }), - delInSpace: (apiClient: ApiClient, spaceId: string, key: string) => apiClient.delete(`s/${spaceId}/internal/user_storage/${key}`, { headers: headers(), diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts index a1cf56ab2de5a..541adc49b71ed 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/scope_isolation.spec.ts @@ -46,9 +46,9 @@ apiTest.describe('User Storage - Scope Isolation', { tag: [...tags.stateful.clas async ({ apiClient }) => { await h.put(apiClient, 'test:string_key', 'default-space-value'); - const response = await h.getInSpace(apiClient, TEST_SPACE); + const response = await h.getKeyInSpace(apiClient, TEST_SPACE, 'test:string_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:string_key']).toBe('default_value'); + expect(response.body.value).toBe('default_value'); } ); @@ -57,17 +57,17 @@ apiTest.describe('User Storage - Scope Isolation', { tag: [...tags.stateful.clas async ({ apiClient }) => { await h.put(apiClient, 'test:number_key', 999); - const response = await h.getInSpace(apiClient, TEST_SPACE); + const response = await h.getKeyInSpace(apiClient, TEST_SPACE, 'test:number_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:number_key']).toBe(999); + expect(response.body.value).toBe(999); } ); apiTest('global key set from another space is visible everywhere', async ({ apiClient }) => { await h.putInSpace(apiClient, TEST_SPACE, 'test:boolean_key', true); - const response = await h.get(apiClient); + const response = await h.getKey(apiClient, 'test:boolean_key'); expect(response).toHaveStatusCode(200); - expect(response.body['test:boolean_key']).toBe(true); + expect(response.body.value).toBe(true); }); }); diff --git a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts index 7a3d2093b7eb0..97dad3345afbf 100644 --- a/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts +++ b/src/core/packages/user-storage/test/scout_user_storage/api/tests/validation.spec.ts @@ -81,26 +81,30 @@ apiTest.describe('User Storage - Schema Validation', { tag: [...tags.stateful.cl apiTest('PUT preserves empty string', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:string_key', '')).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:string_key']).toBe(''); + const response = await h.getKey(apiClient, 'test:string_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(''); }); apiTest('PUT preserves zero', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', 0)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(0); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(0); }); apiTest('PUT preserves negative number', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:number_key', -10)).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:number_key']).toBe(-10); + const response = await h.getKey(apiClient, 'test:number_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toBe(-10); }); apiTest('PUT preserves empty array', async ({ apiClient }) => { expect(await h.put(apiClient, 'test:array_key', [])).toHaveStatusCode(200); - const response = await h.get(apiClient); - expect(response.body['test:array_key']).toStrictEqual([]); + const response = await h.getKey(apiClient, 'test:array_key'); + expect(response).toHaveStatusCode(200); + expect(response.body.value).toStrictEqual([]); }); apiTest('DELETE on a key that was never set is a no-op', async ({ apiClient }) => { diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts new file mode 100644 index 0000000000000..72df09a207161 --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/playwright.config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createPlaywrightConfig } from '@kbn/scout'; + +export default createPlaywrightConfig({ + testDir: './tests', +}); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts new file mode 100644 index 0000000000000..8d498e9e91ab0 --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tests/render.spec.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { test, tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/ui'; + +// Mirrors the `register()` call in +// `src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts`. +// Keep both in sync when changing the test fixture. +const TEST_STRING_KEY_DEFAULT = 'default_value'; +const LAZY_TEST_STRING_KEY_DEFAULT = 'lazy_default_value'; + +test.describe('User Storage - first paint', { tag: [...tags.stateful.classic] }, () => { + test.beforeEach(async ({ browserAuth, page }) => { + await browserAuth.loginAsViewer(); + await page.gotoApp('userStorageTest'); + }); + + test('renders the registered default for test:string_key on first paint', async ({ page }) => { + await expect(page.testSubj.locator('userStorageTest:string-key-value')).toHaveText( + TEST_STRING_KEY_DEFAULT + ); + }); + + test('renders the registered default for test:string_key_lazy', async ({ page }) => { + await expect(page.testSubj.locator('userStorageTest:lazy-string-key-value')).toHaveText( + LAZY_TEST_STRING_KEY_DEFAULT + ); + }); +}); diff --git a/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json new file mode 100644 index 0000000000000..42a57a45bc83e --- /dev/null +++ b/src/core/packages/user-storage/test/scout_user_storage/ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["**/*"], + "kbn_references": [ + "@kbn/scout" + ], + "exclude": ["target/**/*"] +} diff --git a/src/core/server/integration_tests/user_storage/remove.test.ts b/src/core/server/integration_tests/user_storage/remove.test.ts index ac80ba80b3ad2..7fcaa07efaea5 100644 --- a/src/core/server/integration_tests/user_storage/remove.test.ts +++ b/src/core/server/integration_tests/user_storage/remove.test.ts @@ -110,12 +110,4 @@ describe('UserStorage remove() / null-merge behavior', () => { expect(await userStorage.get('test:string_a')).toBe('default_a'); expect(await userStorage.get('test:string_b')).toBe('value_b'); }); - - it('getAll() treats null the same as undefined (returns default)', async () => { - await userStorage.set('test:string_a', 'custom_value'); - await userStorage.remove('test:string_a'); - - const all = await userStorage.getAll(); - expect(all['test:string_a']).toBe('default_a'); - }); }); diff --git a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts index efa99a8048df9..ad5c59f4601fd 100644 --- a/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/user_storage/stateful/classic.stateful.config.ts @@ -20,7 +20,7 @@ export const servers: ScoutServerConfig = { ...defaultConfig.kbnTestServer.serverArgs, `--plugin-path=${resolve( REPO_ROOT, - 'src/core/packages/user-storage/test/plugins/user_storage_test' + 'src/platform/test/user_storage/plugins/user_storage_test' )}`, ], }, diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc b/src/platform/test/user_storage/plugins/user_storage_test/kibana.jsonc similarity index 92% rename from src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc rename to src/platform/test/user_storage/plugins/user_storage_test/kibana.jsonc index a633994b31eae..70c0bc7afa223 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/kibana.jsonc +++ b/src/platform/test/user_storage/plugins/user_storage_test/kibana.jsonc @@ -7,7 +7,7 @@ "plugin": { "id": "userStorageTest", "server": true, - "browser": false, + "browser": true, "configPath": [ "user_storage_test" ] diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml b/src/platform/test/user_storage/plugins/user_storage_test/moon.yml similarity index 84% rename from src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml rename to src/platform/test/user_storage/plugins/user_storage_test/moon.yml index e50612c364368..94950dff066c7 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/moon.yml +++ b/src/platform/test/user_storage/plugins/user_storage_test/moon.yml @@ -15,9 +15,10 @@ project: description: Moon project for @kbn/user-storage-test-plugin channel: '' owner: '@elastic/appex-sharedux' - sourceRoot: src/core/packages/user-storage/test/plugins/user_storage_test + sourceRoot: src/platform/test/user_storage/plugins/user_storage_test dependsOn: - '@kbn/core' + - '@kbn/core-user-storage-browser' - '@kbn/zod' tags: - plugin @@ -27,5 +28,7 @@ tags: fileGroups: src: - server/**/*.ts + - public/**/*.ts + - public/**/*.tsx - '!target/**/*' tasks: {} diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/package.json b/src/platform/test/user_storage/plugins/user_storage_test/package.json similarity index 54% rename from src/core/packages/user-storage/test/plugins/user_storage_test/package.json rename to src/platform/test/user_storage/plugins/user_storage_test/package.json index e2f0d02f61087..cb683f2aa9c22 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/package.json +++ b/src/platform/test/user_storage/plugins/user_storage_test/package.json @@ -1,14 +1,14 @@ { "name": "@kbn/user-storage-test-plugin", "version": "1.0.0", - "main": "target/test/plugins/user_storage_test", + "main": "target/test/user_storage/plugins/user_storage_test", "kibana": { "version": "kibana", "templateVersion": "1.0.0" }, "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", "scripts": { - "kbn": "node ../../../../../../../../scripts/kbn.js", - "build": "rm -rf './target' && ../../../../../../../../node_modules/.bin/tsc" + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" } } diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/application.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/application.tsx new file mode 100644 index 0000000000000..1d1cbf6c958a3 --- /dev/null +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/application.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import type { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { App } from './components/app'; + +export const renderApp = (core: CoreStart, { element }: AppMountParameters) => { + ReactDOM.render(, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx new file mode 100644 index 0000000000000..3f31c503e46fa --- /dev/null +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/components/app.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { + UserStorageProvider, + useUserStorage, + type IUserStorageClient, +} from '@kbn/core-user-storage-browser'; + +const StringKeyValue = () => { + const [preloadedStringValue] = useUserStorage('test:string_key'); + const [lazyStringValue] = useUserStorage('test:string_key_lazy'); + return ( + <> +
+ String key (preloaded): + {preloadedStringValue} +
+
+ String key (lazy): + {lazyStringValue} +
+ + ); +}; + +export const App = ({ userStorage }: { userStorage: IUserStorageClient }) => ( + +

User Storage Test

+ +
+); diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/index.ts b/src/platform/test/user_storage/plugins/user_storage_test/public/index.ts new file mode 100644 index 0000000000000..7be32acae853c --- /dev/null +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { UserStorageTestPlugin } from './plugin'; + +export const plugin = () => new UserStorageTestPlugin(); diff --git a/src/platform/test/user_storage/plugins/user_storage_test/public/plugin.ts b/src/platform/test/user_storage/plugins/user_storage_test/public/plugin.ts new file mode 100644 index 0000000000000..24f52856a69e4 --- /dev/null +++ b/src/platform/test/user_storage/plugins/user_storage_test/public/plugin.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { AppMountParameters, CoreSetup, Plugin } from '@kbn/core/public'; + +export class UserStorageTestPlugin implements Plugin { + public setup(core: CoreSetup): void { + core.application.register({ + id: 'userStorageTest', + title: 'User Storage Test', + async mount(params: AppMountParameters) { + const { renderApp } = await import('./application'); + const [coreStart] = await core.getStartServices(); + return renderApp(coreStart, params); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/server/index.ts b/src/platform/test/user_storage/plugins/user_storage_test/server/index.ts similarity index 100% rename from src/core/packages/user-storage/test/plugins/user_storage_test/server/index.ts rename to src/platform/test/user_storage/plugins/user_storage_test/server/index.ts diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts b/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts similarity index 85% rename from src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts rename to src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts index fea8f7f881c64..3f754eade5bd1 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/server/plugin.ts +++ b/src/platform/test/user_storage/plugins/user_storage_test/server/plugin.ts @@ -17,11 +17,19 @@ export class UserStorageTestPlugin implements Plugin { schema: z.string(), defaultValue: 'default_value', scope: 'space', + preload: true, + }, + 'test:string_key_lazy': { + schema: z.string(), + defaultValue: 'lazy_default_value', + scope: 'space', + preload: false, // make it lazy }, 'test:number_key': { schema: z.number(), defaultValue: 42, scope: 'global', + preload: true, }, 'test:object_key': { schema: z.object({ @@ -38,16 +46,19 @@ export class UserStorageTestPlugin implements Plugin { pinnedItems: [], }, scope: 'space', + preload: true, }, 'test:boolean_key': { schema: z.boolean(), defaultValue: false, scope: 'global', + preload: true, }, 'test:array_key': { schema: z.array(z.string()), defaultValue: [], scope: 'space', + preload: true, }, }); } diff --git a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json b/src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json similarity index 67% rename from src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json rename to src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json index d29853c8a0fcf..2a62744da5c0e 100644 --- a/src/core/packages/user-storage/test/plugins/user_storage_test/tsconfig.json +++ b/src/platform/test/user_storage/plugins/user_storage_test/tsconfig.json @@ -5,13 +5,16 @@ }, "include": [ "server/**/*.ts", - "../../../../../../../../typings/**/*", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../../../typings/**/*", ], "exclude": [ "target/**/*", ], "kbn_references": [ "@kbn/core", + "@kbn/core-user-storage-browser", "@kbn/zod", ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 3d95e4fc9c784..0c9c287f94ac8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -894,6 +894,12 @@ "@kbn/core-user-settings-server-internal/*": ["src/core/packages/user-settings/server-internal/*"], "@kbn/core-user-settings-server-mocks": ["src/core/packages/user-settings/server-mocks"], "@kbn/core-user-settings-server-mocks/*": ["src/core/packages/user-settings/server-mocks/*"], + "@kbn/core-user-storage-browser": ["src/core/packages/user-storage/browser"], + "@kbn/core-user-storage-browser/*": ["src/core/packages/user-storage/browser/*"], + "@kbn/core-user-storage-browser-internal": ["src/core/packages/user-storage/browser-internal"], + "@kbn/core-user-storage-browser-internal/*": ["src/core/packages/user-storage/browser-internal/*"], + "@kbn/core-user-storage-browser-mocks": ["src/core/packages/user-storage/browser-mocks"], + "@kbn/core-user-storage-browser-mocks/*": ["src/core/packages/user-storage/browser-mocks/*"], "@kbn/core-user-storage-common": ["src/core/packages/user-storage/common"], "@kbn/core-user-storage-common/*": ["src/core/packages/user-storage/common/*"], "@kbn/core-user-storage-server": ["src/core/packages/user-storage/server"], @@ -2652,8 +2658,8 @@ "@kbn/user-profile-examples-plugin/*": ["examples/user_profile_examples/*"], "@kbn/user-profiles-consumer-plugin": ["x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer"], "@kbn/user-profiles-consumer-plugin/*": ["x-pack/platform/test/security_api_integration/plugins/user_profiles_consumer/*"], - "@kbn/user-storage-test-plugin": ["src/core/packages/user-storage/test/plugins/user_storage_test"], - "@kbn/user-storage-test-plugin/*": ["src/core/packages/user-storage/test/plugins/user_storage_test/*"], + "@kbn/user-storage-test-plugin": ["src/platform/test/user_storage/plugins/user_storage_test"], + "@kbn/user-storage-test-plugin/*": ["src/platform/test/user_storage/plugins/user_storage_test/*"], "@kbn/utility-types": ["src/platform/packages/shared/kbn-utility-types"], "@kbn/utility-types/*": ["src/platform/packages/shared/kbn-utility-types/*"], "@kbn/utility-types-jest": ["src/platform/packages/shared/kbn-utility-types-jest"], diff --git a/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts b/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts index baa6bb77baf73..6dccb03cab59b 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/datavisualizer/file_based/util.ts @@ -44,6 +44,7 @@ export function buildDependencies(services: StartServices): FileUploadStartDepen pricing: services.pricing, security: services.security, userProfile: services.userProfile, + userStorage: services.userStorage, rendering: services.rendering, }, }; diff --git a/yarn.lock b/yarn.lock index 1650309915498..7fe582c188462 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6263,6 +6263,18 @@ version "0.0.0" uid "" +"@kbn/core-user-storage-browser-internal@link:src/core/packages/user-storage/browser-internal": + version "0.0.0" + uid "" + +"@kbn/core-user-storage-browser-mocks@link:src/core/packages/user-storage/browser-mocks": + version "0.0.0" + uid "" + +"@kbn/core-user-storage-browser@link:src/core/packages/user-storage/browser": + version "0.0.0" + uid "" + "@kbn/core-user-storage-common@link:src/core/packages/user-storage/common": version "0.0.0" uid "" @@ -9783,7 +9795,7 @@ version "0.0.0" uid "" -"@kbn/user-storage-test-plugin@link:src/core/packages/user-storage/test/plugins/user_storage_test": +"@kbn/user-storage-test-plugin@link:src/platform/test/user_storage/plugins/user_storage_test": version "0.0.0" uid ""