diff --git a/docs/developer_docs/extensions/storage.md b/docs/developer_docs/extensions/storage.md index 2c48a154e4b..951b0a8cdf1 100644 --- a/docs/developer_docs/extensions/storage.md +++ b/docs/developer_docs/extensions/storage.md @@ -30,11 +30,11 @@ Each extension receives its own isolated storage namespace. When Superset loads ## Storage Tiers -| Tier | Storage Type | Module | Use Case | -|------|--------------|--------|----------| -| 1 | Browser storage | `localState`, `sessionState` | UI state, wizard progress, draft forms | -| 2 | Server-side cache | `ephemeralState` | Job progress, temporary results | -| 3 | Database | `persistentState` | User preferences, extension config (coming soon) | +| Tier | Storage Type | Context Property | Use Case | +|------|--------------|------------------|----------| +| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms | +| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results | +| 3 | Database | `ctx.storage.persistent` | User preferences, extension config (coming soon) | ## Tier 1: Local State @@ -42,7 +42,7 @@ Browser-based storage that persists on the user's device. Use this for UI state ### Why Use the API Instead of localStorage Directly? -You might wonder why extensions should use `localState` instead of directly accessing `window.localStorage`. The managed API provides several benefits: +You might wonder why extensions should use `ctx.storage.local` instead of directly accessing `window.localStorage`. The managed API provides several benefits: - **Automatic namespacing**: Each extension's data is isolated. Two extensions using the same key name won't collide. - **User isolation**: By default, data is scoped to the current user, preventing data leakage between users on shared devices. @@ -55,16 +55,18 @@ You might wonder why extensions should use `localState` instead of directly acce Data persists across browser sessions until explicitly deleted or the user clears browser storage. ```typescript -import { localState } from '@apache-superset/core/storage'; +import { getContext } from '@apache-superset/core/extensions'; + +const ctx = getContext(); // Save sidebar state -await localState.set('sidebar_collapsed', true); +await ctx.storage.local.set('sidebar_collapsed', true); // Retrieve it later -const isCollapsed = await localState.get('sidebar_collapsed'); +const isCollapsed = await ctx.storage.local.get('sidebar_collapsed'); // Remove it -await localState.remove('sidebar_collapsed'); +await ctx.storage.local.remove('sidebar_collapsed'); ``` ### sessionState @@ -72,26 +74,30 @@ await localState.remove('sidebar_collapsed'); Data is cleared when the browser tab is closed. Use for transient state within a single session. ```typescript -import { sessionState } from '@apache-superset/core/storage'; +import { getContext } from '@apache-superset/core/extensions'; + +const ctx = getContext(); // Save wizard progress (lost when tab closes) -await sessionState.set('wizard_step', 3); -await sessionState.set('unsaved_form', { name: 'Draft' }); +await ctx.storage.session.set('wizard_step', 3); +await ctx.storage.session.set('unsaved_form', { name: 'Draft' }); // Retrieve on page reload within same tab -const step = await sessionState.get('wizard_step'); +const step = await ctx.storage.session.get('wizard_step'); ``` ### Shared State -By default, data is scoped to the current user. Use `shared()` for data that should be accessible to all users on the same device. +By default, data is scoped to the current user. Use `shared` for data that should be accessible to all users on the same device. ```typescript -import { localState } from '@apache-superset/core/storage'; +import { getContext } from '@apache-superset/core/extensions'; + +const ctx = getContext(); // Shared across all users on this device -await localState.shared().set('device_id', 'abc-123'); -const deviceId = await localState.shared().get('device_id'); +await ctx.storage.local.shared.set('device_id', 'abc-123'); +const deviceId = await ctx.storage.local.shared.get('device_id'); ``` ### When to Use Tier 1 @@ -114,34 +120,38 @@ Server-side cache storage with automatic TTL expiration. Use for temporary data ### Frontend Usage ```typescript -import { ephemeralState } from '@apache-superset/core/storage'; +import { getContext } from '@apache-superset/core/extensions'; + +const ctx = getContext(); // Store with default TTL (1 hour) -await ephemeralState.set('job_progress', { pct: 42, status: 'running' }); +await ctx.storage.ephemeral.set('job_progress', { pct: 42, status: 'running' }); // Store with custom TTL (5 minutes) -await ephemeralState.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 }); +await ctx.storage.ephemeral.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 }); // Retrieve -const progress = await ephemeralState.get('job_progress'); +const progress = await ctx.storage.ephemeral.get('job_progress'); // Remove -await ephemeralState.remove('job_progress'); +await ctx.storage.ephemeral.remove('job_progress'); ``` ### Backend Usage ```python -from superset_core.extensions.storage import ephemeral_state +from superset_core.extensions.context import get_context + +ctx = get_context() # Store job progress -ephemeral_state.set('job_progress', {'pct': 42, 'status': 'running'}, ttl=3600) +ctx.storage.ephemeral.set('job_progress', {'pct': 42, 'status': 'running'}, ttl=3600) # Retrieve -progress = ephemeral_state.get('job_progress') +progress = ctx.storage.ephemeral.get('job_progress') # Remove -ephemeral_state.remove('job_progress') +ctx.storage.ephemeral.remove('job_progress') ``` ### Shared State @@ -149,17 +159,21 @@ ephemeral_state.remove('job_progress') For data that needs to be visible to all users: ```typescript -import { ephemeralState } from '@apache-superset/core/storage'; +import { getContext } from '@apache-superset/core/extensions'; -await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] }); -const result = await ephemeralState.shared().get('shared_result'); +const ctx = getContext(); + +await ctx.storage.ephemeral.shared.set('shared_result', { data: [1, 2, 3] }); +const result = await ctx.storage.ephemeral.shared.get('shared_result'); ``` ```python -from superset_core.extensions.storage import ephemeral_state +from superset_core.extensions.context import get_context -ephemeral_state.shared().set('shared_result', {'data': [1, 2, 3]}) -result = ephemeral_state.shared().get('shared_result') +ctx = get_context() + +ctx.storage.ephemeral.shared.set('shared_result', {'data': [1, 2, 3]}) +result = ctx.storage.ephemeral.shared.get('shared_result') ``` ### When to Use Tier 2 @@ -184,10 +198,10 @@ Coming soon. | Need | Recommended Tier | |------|------------------| -| UI state (sidebar collapsed, panel sizes) | `localState` | -| Wizard/form progress within a session | `sessionState` | -| Background job progress | `ephemeralState` | -| Temporary computation cache | `ephemeralState` | +| UI state (sidebar collapsed, panel sizes) | `ctx.storage.local` | +| Wizard/form progress within a session | `ctx.storage.session` | +| Background job progress | `ctx.storage.ephemeral` | +| Temporary computation cache | `ctx.storage.ephemeral` | ## Key Patterns @@ -196,7 +210,7 @@ All storage keys are automatically namespaced: | Scope | Key Pattern | |-------|-------------| | User-scoped | `superset-ext:{extension_id}:user:{user_id}:{key}` | -| Shared | `superset-ext:{extension_id}:{key}` | +| Shared | `superset-ext:{extension_id}:shared:{key}` | This ensures: - Extensions cannot accidentally access each other's data diff --git a/superset-core/src/superset_core/extensions/context.py b/superset-core/src/superset_core/extensions/context.py new file mode 100644 index 00000000000..c68d8ed3fdd --- /dev/null +++ b/superset-core/src/superset_core/extensions/context.py @@ -0,0 +1,137 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Extension Context API for superset-core extensions. + +Provides access to the current extension's context, including metadata +and scoped resources like storage. Extensions call `get_context()` to +access their context during execution. + +The context is set by the host (Superset) during extension loading and +is only available within extension code. + +Usage: + from superset_core.extensions.context import get_context + + def setup(): + ctx = get_context() + + # Access extension metadata + print(f"Running {ctx.extension.displayName} v{ctx.extension.version}") + + # Access extension-scoped storage + ctx.storage.ephemeral.set("lastRun", time.time()) + data = ctx.storage.ephemeral.get("cachedData") +""" + +from __future__ import annotations + +from typing import Any, Protocol, TYPE_CHECKING + +if TYPE_CHECKING: + from superset_core.extensions.types import Manifest + + +class StorageAccessor(Protocol): + """Protocol for storage access with user-scoped and shared modes.""" + + def get(self, key: str) -> Any: + """Get a value from storage.""" + ... + + def set(self, key: str, value: Any, ttl: int = 3600) -> None: + """Set a value in storage with optional TTL.""" + ... + + def remove(self, key: str) -> None: + """Remove a value from storage.""" + ... + + @property + def shared(self) -> "StorageAccessor": + """Shared (cross-user) storage accessor.""" + ... + + +class ExtensionStorage(Protocol): + """Extension-scoped storage accessor for all available tiers.""" + + @property + def ephemeral(self) -> StorageAccessor: + """Server-side cache (Redis/Memcached) with TTL.""" + ... + + # Future tiers: + # @property + # def persistent(self) -> StorageAccessor: + # """Database-backed persistent storage.""" + # ... + + +class ExtensionContext(Protocol): + """ + Context object providing extension-specific resources. + + This context is only available during extension execution. + Calling `get_context()` outside of an extension will raise an error. + """ + + @property + def extension(self) -> "Manifest": + """Metadata about the current extension.""" + ... + + @property + def storage(self) -> ExtensionStorage: + """Extension-scoped storage across all available tiers.""" + ... + + +def get_context() -> ExtensionContext: + """ + Get the current extension's context. + + This function returns the context for the currently executing extension, + providing access to extension metadata and scoped resources like storage. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + :returns: The current extension's context. + :raises RuntimeError: If called outside of an extension context. + + Example: + from superset_core.extensions.context import get_context + + ctx = get_context() + + # Access extension metadata + print(f"Extension: {ctx.extension.id}") + print(f"Version: {ctx.extension.version}") + + # Access extension-scoped storage + ctx.storage.ephemeral.set("tempData", data, ttl=3600) + value = ctx.storage.ephemeral.get("tempData") + + # Access shared (cross-user) storage + ctx.storage.ephemeral.shared.set("globalCounter", count) + """ + raise NotImplementedError( + "get_context() must be called within an extension context. " + "This function is replaced by the host during extension loading." + ) diff --git a/superset-core/src/superset_core/extensions/storage/__init__.py b/superset-core/src/superset_core/extensions/storage/__init__.py index 2bc122a2cca..30d19ea3e0e 100644 --- a/superset-core/src/superset_core/extensions/storage/__init__.py +++ b/superset-core/src/superset_core/extensions/storage/__init__.py @@ -37,7 +37,7 @@ Tier 3 - Persistent State (Database) [Future]: All tiers follow the same API pattern: - User-scoped by default (private to current user) - - shared() accessor for data visible to all users + - `shared` accessor for data visible to all users Usage: from superset_core.extensions.storage import ephemeral_state @@ -47,8 +47,8 @@ Usage: ephemeral_state.set('preference', 'compact', ttl=3600) # Shared state (explicit opt-in - visible to all users) - ephemeral_state.shared().get('job_progress') - ephemeral_state.shared().set('job_progress', {'pct': 42}, ttl=3600) + ephemeral_state.shared.get('job_progress') + ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600) # Future: Persistent state # from superset_core.extensions.storage import persistent_state diff --git a/superset-core/src/superset_core/extensions/storage/ephemeral_state.py b/superset-core/src/superset_core/extensions/storage/ephemeral_state.py index 1138a50b71c..69612398417 100644 --- a/superset-core/src/superset_core/extensions/storage/ephemeral_state.py +++ b/superset-core/src/superset_core/extensions/storage/ephemeral_state.py @@ -38,9 +38,9 @@ Usage: ephemeral_state.remove('preference') # Shared state (explicit opt-in - visible to all users) - ephemeral_state.shared().get('job_progress') - ephemeral_state.shared().set('job_progress', {'pct': 42}, ttl=3600) - ephemeral_state.shared().remove('job_progress') + ephemeral_state.shared.get('job_progress') + ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600) + ephemeral_state.shared.remove('job_progress') """ from typing import Any, Protocol @@ -112,23 +112,24 @@ def remove(key: str) -> None: raise NotImplementedError("Function will be replaced during initialization") -def shared() -> EphemeralStateAccessor: - """ - Get a shared (global) ephemeral state accessor. +class _SharedStub: + """Stub for shared accessor that raises NotImplementedError on any operation.""" - Returns an accessor for state that is shared across all users. - Use this for data that needs to be visible to everyone, such as - job progress indicators or shared computation results. + def get(self, key: str) -> Any: + raise NotImplementedError("Accessor will be replaced during initialization") - WARNING: Data stored via shared() is visible to all users of the extension. - Do not store user-specific or sensitive data here. + def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None: + raise NotImplementedError("Accessor will be replaced during initialization") - Host implementations will replace this function during initialization - with a concrete implementation providing actual functionality. + def remove(self, key: str) -> None: + raise NotImplementedError("Accessor will be replaced during initialization") - :returns: An accessor for shared ephemeral state. - """ - raise NotImplementedError("Function will be replaced during initialization") + +#: Shared (global) ephemeral state accessor. +#: Data stored via this accessor is visible to all users of the extension. +#: WARNING: Do not store user-specific or sensitive data here. +#: Host implementations will replace this during initialization. +shared: EphemeralStateAccessor = _SharedStub() __all__ = [ diff --git a/superset-frontend/packages/superset-core/src/extensions/index.ts b/superset-frontend/packages/superset-core/src/extensions/index.ts index 2a21f45e91f..0bcd477f400 100644 --- a/superset-frontend/packages/superset-core/src/extensions/index.ts +++ b/superset-frontend/packages/superset-core/src/extensions/index.ts @@ -24,9 +24,107 @@ * including querying extension metadata and monitoring extension lifecycle events. * Extensions can use this API to discover other extensions and react to changes * in the extension ecosystem. + * + * Extensions can access their own context via `getContext()`, which provides: + * - Extension metadata (id, name, version, etc.) + * - Extension-scoped storage (localStorage, sessionStorage, ephemeral cache) + * + * @example + * ```typescript + * import { extensions } from '@apache-superset/core'; + * + * // Get the current extension's context + * const ctx = extensions.getContext(); + * + * // Access extension metadata + * console.log(`Running ${ctx.extension.name} v${ctx.extension.version}`); + * + * // Access extension-scoped storage + * await ctx.storage.local.set('preference', { theme: 'dark' }); + * await ctx.storage.ephemeral.set('cache', data, { ttl: 300 }); + * ``` */ import { Extension } from '../common'; +import { StorageTier } from '../storage/types'; + +/** + * Extension-scoped storage accessor. + * + * All storage tiers are automatically namespaced to the current extension, + * preventing key collisions between extensions. + */ +export interface ExtensionStorage { + /** + * Browser localStorage - persists across browser sessions. + * Data is scoped to the current extension and user. + */ + local: StorageTier; + + /** + * Browser sessionStorage - cleared when the tab closes. + * Data is scoped to the current extension and user. + */ + session: StorageTier; + + /** + * Server-side cache (Redis/Memcached) with TTL. + * Data is scoped to the current extension and user. + * Use `.shared` for data visible to all users. + */ + ephemeral: StorageTier; +} + +/** + * Context object providing extension-specific resources. + * + * This context is only available during extension execution. + * Calling `getContext()` outside of an extension will throw an error. + */ +export interface ExtensionContext { + /** + * Metadata about the current extension. + */ + extension: Extension; + + /** + * Extension-scoped storage across all tiers. + * All keys are automatically namespaced to prevent collisions. + */ + storage: ExtensionStorage; +} + +/** + * Get the current extension's context. + * + * This function returns the context for the currently executing extension, + * providing access to extension metadata and scoped resources like storage. + * + * @returns The current extension's context. + * @throws Error if called outside of an extension context. + * + * @example + * ```typescript + * import { extensions } from '@apache-superset/core'; + * + * const ctx = extensions.getContext(); + * + * // Access extension metadata + * console.log(`Extension: ${ctx.extension.id}`); + * console.log(`Version: ${ctx.extension.version}`); + * + * // Access extension-scoped storage + * await ctx.storage.local.set('userPref', { sidebar: 'collapsed' }); + * const pref = await ctx.storage.local.get('userPref'); + * + * // Use ephemeral storage with TTL + * await ctx.storage.ephemeral.set('tempData', data, { ttl: 3600 }); + * + * // Access shared (cross-user) storage + * await ctx.storage.ephemeral.shared.set('globalCounter', count); + * ``` + */ +export declare function getContext(): ExtensionContext; /** * Get an extension by its full identifier in the form of: `publisher.name`. diff --git a/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts b/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts index 93c57b544e8..69eec2a87fa 100644 --- a/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts +++ b/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts @@ -27,7 +27,7 @@ import type { JsonValue, StorageAccessor } from './types'; * Not guaranteed to survive server restarts. * * By default, all operations are user-scoped (private to the current user). - * Use shared() to access state that is visible to all users. + * Use `shared` to access state that is visible to all users. * * Cache keys are namespaced automatically: * - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key} @@ -43,9 +43,9 @@ import type { JsonValue, StorageAccessor } from './types'; * await ephemeralState.remove('job_progress'); * * // Shared state (explicit opt-in - visible to all users) - * const result = await ephemeralState.shared().get('shared_result'); - * await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] }); - * await ephemeralState.shared().remove('shared_result'); + * const result = await ephemeralState.shared.get('shared_result'); + * await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] }); + * await ephemeralState.shared.remove('shared_result'); * ``` */ @@ -136,27 +136,25 @@ export declare function set( export declare function remove(key: string): Promise; /** - * Get a shared (global) ephemeral state accessor. + * Shared (global) ephemeral state accessor. * - * Returns an accessor for state that is shared across all users. + * Accessor for state that is shared across all users. * Use this for data that needs to be visible to everyone, such as * job progress indicators or shared computation results. * - * WARNING: Data stored via shared() is visible to all users of the extension. + * WARNING: Data stored via shared is visible to all users of the extension. * Do not store user-specific or sensitive data here. * - * @returns An accessor for shared ephemeral state. - * * @example * ```typescript * // Get shared job progress - * const progress = await ephemeralState.shared().get('computation_progress'); + * const progress = await ephemeralState.shared.get('computation_progress'); * * // Update shared job progress - * await ephemeralState.shared().set('computation_progress', { pct: 75 }); + * await ephemeralState.shared.set('computation_progress', { pct: 75 }); * * // Clear shared state - * await ephemeralState.shared().remove('computation_progress'); + * await ephemeralState.shared.remove('computation_progress'); * ``` */ -export declare function shared(): EphemeralStateAccessor; +export declare const shared: EphemeralStateAccessor; diff --git a/superset-frontend/packages/superset-core/src/storage/index.ts b/superset-frontend/packages/superset-core/src/storage/index.ts index 6f12608b2e3..8f8a0ee671c 100644 --- a/superset-frontend/packages/superset-core/src/storage/index.ts +++ b/superset-frontend/packages/superset-core/src/storage/index.ts @@ -29,7 +29,7 @@ * * All tiers follow the same API pattern: * - User-scoped by default (private to current user) - * - `shared()` accessor for data visible to all users + * - `shared` accessor for data visible to all users * * @example * ```typescript @@ -48,8 +48,8 @@ * const progress = await ephemeralState.get('job_progress'); * * // Shared state (visible to all users) - * await localState.shared().set('device_id', 'abc-123'); - * await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] }); + * await localState.shared.set('device_id', 'abc-123'); + * await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] }); * ``` */ diff --git a/superset-frontend/packages/superset-core/src/storage/localState.ts b/superset-frontend/packages/superset-core/src/storage/localState.ts index 262ac3ed7e2..5a27923d690 100644 --- a/superset-frontend/packages/superset-core/src/storage/localState.ts +++ b/superset-frontend/packages/superset-core/src/storage/localState.ts @@ -25,7 +25,7 @@ * devices or synced to the server). * * By default, all operations are user-scoped (private to the current user). - * Use shared() to access state visible to all users on the same browser. + * Use `shared` to access state visible to all users on the same browser. * * Key patterns: * - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key} @@ -44,8 +44,8 @@ * await localState.remove('sidebar_collapsed'); * * // Shared state (visible to all users on same browser) - * const deviceId = await localState.shared().get('device_id'); - * await localState.shared().set('device_id', 'abc-123'); + * const deviceId = await localState.shared.get('device_id'); + * await localState.shared.set('device_id', 'abc-123'); * ``` */ @@ -100,24 +100,22 @@ export declare function set(key: string, value: JsonValue): Promise; export declare function remove(key: string): Promise; /** - * Get a shared local state accessor. + * Shared local state accessor. * - * Returns an accessor for state that is shared across all users on the + * Accessor for state that is shared across all users on the * same browser/device. Use this for device-specific settings that should * persist regardless of which user is logged in. * - * WARNING: Data stored via shared() is visible to all users on this browser. + * WARNING: Data stored via shared is visible to all users on this browser. * Do not store user-specific or sensitive data here. * - * @returns An accessor for shared local state. - * * @example * ```typescript * // Get device-specific setting - * const deviceId = await localState.shared().get('device_id'); + * const deviceId = await localState.shared.get('device_id'); * * // Set device-specific setting - * await localState.shared().set('last_used_printer', 'HP-1234'); + * await localState.shared.set('last_used_printer', 'HP-1234'); * ``` */ -export declare function shared(): StorageAccessor; +export declare const shared: StorageAccessor; diff --git a/superset-frontend/packages/superset-core/src/storage/sessionState.ts b/superset-frontend/packages/superset-core/src/storage/sessionState.ts index 5dbb3812bc3..05da4a376bb 100644 --- a/superset-frontend/packages/superset-core/src/storage/sessionState.ts +++ b/superset-frontend/packages/superset-core/src/storage/sessionState.ts @@ -25,7 +25,7 @@ * truly transient UI state that should not persist across sessions. * * By default, all operations are user-scoped (private to the current user). - * Use shared() to access state visible to all users on the same browser tab. + * Use `shared` to access state visible to all users on the same browser tab. * * Key patterns: * - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key} @@ -44,8 +44,8 @@ * await sessionState.remove('wizard_step'); * * // Shared state (visible to all users on same tab) - * const tempData = await sessionState.shared().get('temp_data'); - * await sessionState.shared().set('temp_data', { draft: true }); + * const tempData = await sessionState.shared.get('temp_data'); + * await sessionState.shared.set('temp_data', { draft: true }); * ``` */ @@ -102,23 +102,21 @@ export declare function set(key: string, value: JsonValue): Promise; export declare function remove(key: string): Promise; /** - * Get a shared session state accessor. + * Shared session state accessor. * - * Returns an accessor for state that is shared across all users on the + * Accessor for state that is shared across all users on the * same browser tab. Data is cleared when the tab/window is closed. * - * WARNING: Data stored via shared() is visible to all users on this tab. + * WARNING: Data stored via shared is visible to all users on this tab. * Do not store user-specific or sensitive data here. * - * @returns An accessor for shared session state. - * * @example * ```typescript * // Store temporary shared data - * await sessionState.shared().set('temp_computation', result); + * await sessionState.shared.set('temp_computation', result); * * // Retrieve temporary shared data - * const result = await sessionState.shared().get('temp_computation'); + * const result = await sessionState.shared.get('temp_computation'); * ``` */ -export declare function shared(): StorageAccessor; +export declare const shared: StorageAccessor; diff --git a/superset-frontend/packages/superset-core/src/storage/types.ts b/superset-frontend/packages/superset-core/src/storage/types.ts index c7b169236c6..2d6ec130896 100644 --- a/superset-frontend/packages/superset-core/src/storage/types.ts +++ b/superset-frontend/packages/superset-core/src/storage/types.ts @@ -72,14 +72,12 @@ export interface StorageAccessor { /** * Base interface for a storage tier. - * All storage tiers implement this interface with user-scoped default and shared() accessor. + * All storage tiers implement this interface with user-scoped default and shared accessor. */ export interface StorageTier extends StorageAccessor { /** - * Get a shared storage accessor. - * Data stored via shared() is visible to all users. - * - * @returns An accessor for shared storage. + * Shared storage accessor. + * Data stored via shared is visible to all users. */ - shared(): StorageAccessor; + shared: StorageAccessor; } diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts index ae49a135aed..2368c367d46 100644 --- a/superset-frontend/src/core/extensions/index.ts +++ b/superset-frontend/src/core/extensions/index.ts @@ -19,6 +19,70 @@ import { extensions as extensionsApi } from '@apache-superset/core'; import ExtensionsLoader from 'src/extensions/ExtensionsLoader'; +type ExtensionContext = extensionsApi.ExtensionContext; + +/** + * Current extension context for ambient context pattern. + * Set before executing extension code, restored after. + */ +let currentContext: ExtensionContext | null = null; + +/** + * Get the current extension context. + * @internal + */ +export function getCurrentContext(): ExtensionContext | null { + return currentContext; +} + +/** + * Execute a callback with the given extension context. + * Saves the previous context and restores it after execution, + * supporting nested context switches. + * + * @param ctx The extension context to use during execution + * @param fn The callback to execute + * @returns The result of the callback + * @internal + */ +export function useContext(ctx: ExtensionContext, fn: () => T): T { + const previousContext = currentContext; + currentContext = ctx; + try { + return fn(); + } finally { + currentContext = previousContext; + } +} + +/** + * Async version of useContext for async callbacks. + * @internal + */ +export async function useContextAsync( + ctx: ExtensionContext, + fn: () => Promise, +): Promise { + const previousContext = currentContext; + currentContext = ctx; + try { + return await fn(); + } finally { + currentContext = previousContext; + } +} + +const getContext: typeof extensionsApi.getContext = () => { + if (!currentContext) { + throw new Error( + 'getContext() must be called within an extension context. ' + + 'Ensure this code is being executed during extension loading or ' + + 'within an extension callback.', + ); + } + return currentContext; +}; + const getExtension: typeof extensionsApi.getExtension = id => ExtensionsLoader.getInstance().getExtension(id); @@ -26,6 +90,7 @@ const getAllExtensions: typeof extensionsApi.getAllExtensions = () => ExtensionsLoader.getInstance().getExtensions(); export const extensions: typeof extensionsApi = { + getContext, getExtension, getAllExtensions, }; diff --git a/superset-frontend/src/core/storage/ephemeralState.ts b/superset-frontend/src/core/storage/ephemeralState.ts index 0f4766d9b98..b3ce47dc694 100644 --- a/superset-frontend/src/core/storage/ephemeralState.ts +++ b/superset-frontend/src/core/storage/ephemeralState.ts @@ -121,81 +121,8 @@ export const ephemeralState: typeof storageApi.ephemeralState = { await SupersetClient.delete({ endpoint: url }); }, - shared(): StorageTypes.ephemeralState.EphemeralStateAccessor { + get shared(): StorageTypes.ephemeralState.EphemeralStateAccessor { const extensionId = getCurrentExtensionId(); return new SharedEphemeralStateAccessor(extensionId); }, }; - -/** - * Create ephemeral state implementation bound to a specific extension ID. - */ -export function createBoundEphemeralState( - extensionId: string, -): typeof storageApi.ephemeralState { - class BoundSharedEphemeralAccessor - implements StorageTypes.ephemeralState.EphemeralStateAccessor - { - async get(key: string): Promise { - const url = buildEphemeralStateUrl(extensionId, key, true); - const response = await SupersetClient.get({ endpoint: url }); - return response.json?.result ?? null; - } - - async set( - key: string, - value: StorageTypes.JsonValue, - options?: StorageTypes.ephemeralState.SetOptions, - ): Promise { - const url = buildEphemeralStateUrl(extensionId, key, true); - await SupersetClient.put({ - endpoint: url, - body: JSON.stringify({ - value, - ttl: options?.ttl ?? DEFAULT_TTL, - }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async remove(key: string): Promise { - const url = buildEphemeralStateUrl(extensionId, key, true); - await SupersetClient.delete({ endpoint: url }); - } - } - - return { - DEFAULT_TTL, - - async get(key: string): Promise { - const url = buildEphemeralStateUrl(extensionId, key, false); - const response = await SupersetClient.get({ endpoint: url }); - return response.json?.result ?? null; - }, - - async set( - key: string, - value: StorageTypes.JsonValue, - options?: StorageTypes.ephemeralState.SetOptions, - ): Promise { - const url = buildEphemeralStateUrl(extensionId, key, false); - await SupersetClient.put({ - endpoint: url, - body: JSON.stringify({ - value, - ttl: options?.ttl ?? DEFAULT_TTL, - }), - headers: { 'Content-Type': 'application/json' }, - }); - }, - - async remove(key: string): Promise { - const url = buildEphemeralStateUrl(extensionId, key, false); - await SupersetClient.delete({ endpoint: url }); - }, - - shared(): StorageTypes.ephemeralState.EphemeralStateAccessor { - return new BoundSharedEphemeralAccessor(); - }, - }; -} diff --git a/superset-frontend/src/core/storage/index.ts b/superset-frontend/src/core/storage/index.ts index 11287ea4604..3165787cd8e 100644 --- a/superset-frontend/src/core/storage/index.ts +++ b/superset-frontend/src/core/storage/index.ts @@ -30,24 +30,9 @@ */ import { storage as storageApi } from '@apache-superset/core'; -import { localState, createBoundBrowserStorage } from './localState'; +import { localState } from './localState'; import { sessionState } from './sessionState'; -import { ephemeralState, createBoundEphemeralState } from './ephemeralState'; - -/** - * Create a storage instance bound to a specific extension ID. - * Used by ExtensionsLoader to provide pre-bound storage to extensions. - * - * @param extensionId The extension ID to bind storage to. - * @returns A storage object with all tiers bound to the extension. - */ -export function forExtension(extensionId: string): typeof storageApi { - return { - localState: createBoundBrowserStorage(localStorage, extensionId), - sessionState: createBoundBrowserStorage(sessionStorage, extensionId), - ephemeralState: createBoundEphemeralState(extensionId), - }; -} +import { ephemeralState } from './ephemeralState'; export const storage: typeof storageApi = { localState, diff --git a/superset-frontend/src/core/storage/localState.ts b/superset-frontend/src/core/storage/localState.ts index 18fe346686a..f31851645ca 100644 --- a/superset-frontend/src/core/storage/localState.ts +++ b/superset-frontend/src/core/storage/localState.ts @@ -81,64 +81,13 @@ export function createBrowserStorageImpl( storage.removeItem(storageKey); }, - shared(): StorageTypes.StorageAccessor { + get shared(): StorageTypes.StorageAccessor { const extensionId = getCurrentExtensionId(); return new SharedAccessor(extensionId); }, }; } -/** - * Create browser storage implementation bound to a specific extension ID. - */ -export function createBoundBrowserStorage( - browserStorage: Storage, - extensionId: string, -): typeof storageApi.localState { - class BoundSharedAccessor implements StorageTypes.StorageAccessor { - async get(key: string): Promise { - const storageKey = buildKey(extensionId, key); - const value = browserStorage.getItem(storageKey); - return value ? JSON.parse(value) : null; - } - - async set(key: string, value: StorageTypes.JsonValue): Promise { - const storageKey = buildKey(extensionId, key); - browserStorage.setItem(storageKey, JSON.stringify(value)); - } - - async remove(key: string): Promise { - const storageKey = buildKey(extensionId, key); - browserStorage.removeItem(storageKey); - } - } - - return { - async get(key: string): Promise { - const userId = getCurrentUserId(); - const storageKey = buildKey(extensionId, 'user', userId, key); - const value = browserStorage.getItem(storageKey); - return value ? JSON.parse(value) : null; - }, - - async set(key: string, value: StorageTypes.JsonValue): Promise { - const userId = getCurrentUserId(); - const storageKey = buildKey(extensionId, 'user', userId, key); - browserStorage.setItem(storageKey, JSON.stringify(value)); - }, - - async remove(key: string): Promise { - const userId = getCurrentUserId(); - const storageKey = buildKey(extensionId, 'user', userId, key); - browserStorage.removeItem(storageKey); - }, - - shared(): StorageTypes.StorageAccessor { - return new BoundSharedAccessor(); - }, - }; -} - /** * Local state implementation using localStorage. */ diff --git a/superset-frontend/src/core/storage/utils.ts b/superset-frontend/src/core/storage/utils.ts index 3fccf2b8e66..92675f4b733 100644 --- a/superset-frontend/src/core/storage/utils.ts +++ b/superset-frontend/src/core/storage/utils.ts @@ -22,6 +22,7 @@ */ import getBootstrapData from 'src/utils/getBootstrapData'; +import { getCurrentContext } from 'src/core/extensions'; // Key prefix for extension storage export const KEY_PREFIX = 'superset-ext'; @@ -30,20 +31,18 @@ export const KEY_PREFIX = 'superset-ext'; export const DEFAULT_TTL = 3600; /** - * Get the current extension ID from context. - * This is injected by the extension loader when running extension code. + * Get the current extension ID from ambient context. + * The context is set by ExtensionsLoader when executing extension code. */ export function getCurrentExtensionId(): string { - const extensionId = ( - window as unknown as { __SUPERSET_EXTENSION_ID__?: string } - ).__SUPERSET_EXTENSION_ID__; - if (!extensionId) { + const context = getCurrentContext(); + if (!context) { throw new Error( 'Storage APIs can only be used within an extension context. ' + 'Ensure this code is being executed by an extension.', ); } - return extensionId; + return context.extension.id; } /** diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts index 72f872230b9..536a380dea2 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.ts @@ -18,10 +18,13 @@ */ import { SupersetClient } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; -import type { common as core } from '@apache-superset/core'; -import { forExtension } from 'src/core/storage'; +import type { common as core, extensions } from '@apache-superset/core'; +import { storage } from 'src/core/storage'; +import { useContext } from 'src/core/extensions'; import './types'; +type ExtensionContext = extensions.ExtensionContext; + type Extension = core.Extension; /** @@ -139,13 +142,23 @@ class ExtensionsLoader { const factory = await container.get('./index'); - // Bind storage to this extension before executing the module. - // The extension's imports resolve via webpack externals at load time, - // capturing this bound instance. - window.superset.storage = forExtension(id); + // Create the extension context with ambient storage. + // Storage methods will get the extension ID from the ambient context. + const context: ExtensionContext = { + extension, + storage: { + local: storage.localState, + session: storage.sessionState, + ephemeral: storage.ephemeralState, + }, + }; - // Execute the module factory - side effects fire registrations - factory(); + // Execute module with ambient context. + // Extensions call getContext() to access this. + // Context is automatically restored after execution. + useContext(context, () => { + factory(); + }); } /** diff --git a/superset-frontend/src/extensions/types.ts b/superset-frontend/src/extensions/types.ts index ecec4d78d96..af5af9e5044 100644 --- a/superset-frontend/src/extensions/types.ts +++ b/superset-frontend/src/extensions/types.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import type { storage as storageTypes } from '@apache-superset/core'; import type { authentication, core, @@ -25,6 +24,7 @@ import type { extensions, menus, sqlLab, + storage, views, } from 'src/core'; @@ -38,9 +38,7 @@ declare global { extensions: typeof extensions; menus: typeof menus; sqlLab: typeof sqlLab; - // Use the @apache-superset/core type (what extensions see), - // not the host's extended type with forExtension - storage: typeof storageTypes; + storage: typeof storage; views: typeof views; }; } diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py index 48ccb156471..9a2bc66056f 100644 --- a/superset/core/api/core_api_injection.py +++ b/superset/core/api/core_api_injection.py @@ -245,6 +245,18 @@ def inject_storage_implementations() -> None: core_ephemeral_state.shared = EphemeralStateImpl.shared +def inject_extension_context() -> None: + """ + Replace abstract get_context in superset_core.extensions.context + with concrete implementation from Superset. + """ + import superset_core.extensions.context as core_context + + from superset.extensions.context import get_context + + core_context.get_context = get_context + + def initialize_core_api_dependencies() -> None: """ Initialize all dependency injections for the superset-core API. @@ -259,3 +271,4 @@ def initialize_core_api_dependencies() -> None: inject_task_implementations() inject_rest_api_implementations() inject_storage_implementations() + inject_extension_context() diff --git a/superset/extensions/context.py b/superset/extensions/context.py index c2b1ac9ab61..b299d949bac 100644 --- a/superset/extensions/context.py +++ b/superset/extensions/context.py @@ -16,75 +16,118 @@ # under the License. """ -Extension Context Management - provides ambient context during extension loading. +Extension Context Management - provides ambient context for extensions. -This module provides a thread-local context system that allows decorators to -automatically detect whether they are being called in host or extension code -during extension loading. +This module provides a context system using Python's contextvars that allows +extensions to access their context (metadata and scoped resources) via get_context(). + +The context is set during extension loading and when extension callbacks are invoked. +Uses ContextVar for thread-safe and async-safe context management with automatic +save/restore for nested contexts. """ from __future__ import annotations -import contextlib -from threading import local -from typing import Any, Generator +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Iterator from superset_core.extensions.types import Manifest -# Thread-local storage for extension context -_extension_context: local = local() + +class ExtensionStorage: + """Extension storage with all available tiers.""" + + @property + def ephemeral(self) -> Any: + from superset.extensions.storage.ephemeral_state import EphemeralStateImpl + + return EphemeralStateImpl -class ExtensionContext: - """Manages ambient extension context during loading.""" - - def __init__(self, manifest: Manifest): - self.manifest = manifest - - def __enter__(self) -> "ExtensionContext": - if getattr(_extension_context, "current", None) is not None: - current_extension = _extension_context.current.manifest.id - raise RuntimeError( - f"Cannot initialize extension {self.manifest.id} while extension " - f"{current_extension} is already being initialized. " - f"Nested extension initialization is not supported." - ) - - _extension_context.current = self - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - # Clear the current context - _extension_context.current = None - - -class ExtensionContextWrapper: - """Wrapper for extension context with extensible properties.""" +class ConcreteExtensionContext: + """Concrete implementation of ExtensionContext for the host.""" def __init__(self, manifest: Manifest): self._manifest = manifest + self._storage = ExtensionStorage() + + @property + def extension(self) -> Manifest: + """Extension metadata (new API).""" + return self._manifest @property def manifest(self) -> Manifest: - """Get the extension manifest.""" + """Extension manifest (for backward compatibility).""" return self._manifest - # Future: Add other context properties here - # @property - # def security_context(self) -> SecurityContext: ... - # @property - # def build_info(self) -> BuildInfo: ... + @property + def storage(self) -> ExtensionStorage: + return self._storage -def get_current_extension_context() -> ExtensionContextWrapper | None: - """Get the currently active extension context wrapper, or None if in host code.""" - if context := getattr(_extension_context, "current", None): - return ExtensionContextWrapper(context.manifest) - return None +# Context variable for ambient extension context pattern. +# Thread-safe and async-safe via Python's contextvars. +_current_context: ContextVar[ConcreteExtensionContext | None] = ContextVar( + "extension_context", default=None +) -@contextlib.contextmanager -def extension_context(manifest: Manifest) -> Generator[None, None, None]: - """Context manager for setting extension context during loading.""" - with ExtensionContext(manifest): +def get_context() -> ConcreteExtensionContext: + """ + Get the current extension's context. + + This is the host implementation that replaces the stub in superset_core. + + :returns: The current extension's context. + :raises RuntimeError: If called outside of an extension context. + """ + context = _current_context.get() + if context is None: + raise RuntimeError( + "get_context() must be called within an extension context. " + "Ensure this code is being executed during extension loading or " + "within an extension callback." + ) + return context + + +def get_current_extension_context() -> ConcreteExtensionContext | None: + """Get the currently active extension context, or None if in host code.""" + return _current_context.get() + + +@contextmanager +def use_context(ctx: ConcreteExtensionContext) -> Iterator[None]: + """ + Context manager to set ambient context for extension execution. + + Used to establish the ambient context before executing extension code. + The context is automatically restored after execution, supporting nested + context switches. + + :param ctx: ExtensionContext to set as the current context + :yields: None + """ + token = _current_context.set(ctx) + try: yield + finally: + _current_context.reset(token) + + +@contextmanager +def extension_context(manifest: Manifest) -> Iterator[ConcreteExtensionContext]: + """ + Context manager for setting extension context during loading. + + Creates a new ExtensionContext for the given manifest and sets it as + the current context. Supports nested contexts via ContextVar tokens. + + :param manifest: The extension manifest + :yields: The created ExtensionContext + """ + ctx = ConcreteExtensionContext(manifest) + with use_context(ctx): + yield ctx diff --git a/superset/extensions/storage/ephemeral_state.py b/superset/extensions/storage/ephemeral_state.py index e57f9e775db..b326bd38cf2 100644 --- a/superset/extensions/storage/ephemeral_state.py +++ b/superset/extensions/storage/ephemeral_state.py @@ -74,14 +74,13 @@ class SharedEphemeralStateAccessor: Accessor for shared (global) ephemeral state. Data stored via this accessor is visible to all users of the extension. + Extension ID is resolved lazily on each operation from the current context. """ - def __init__(self, extension_id: str): - self._extension_id = extension_id - def _build_key(self, key: str) -> str: """Build a shared (global) cache key.""" - return _build_cache_key(KEY_PREFIX, self._extension_id, "shared", key) + extension_id = _get_extension_id() + return _build_cache_key(KEY_PREFIX, extension_id, "shared", key) def get(self, key: str) -> Any: """ @@ -122,7 +121,7 @@ class EphemeralStateImpl: superset_core.extensions.storage.ephemeral_state. By default, all operations are user-scoped (private to the current user). - Use shared() to access state that is visible to all users. + Use `shared` to access state that is visible to all users. """ @staticmethod @@ -169,24 +168,7 @@ class EphemeralStateImpl: cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key) cache_manager.extension_ephemeral_state_cache.delete(cache_key) - @staticmethod - def shared() -> SharedEphemeralStateAccessor: - """ - Get a shared (global) ephemeral state accessor. - - Returns an accessor for state that is shared across all users. - Use this for data that needs to be visible to everyone. - - WARNING: Data stored via shared() is visible to all users of the extension. - - :returns: An accessor for shared ephemeral state. - """ - extension_id = _get_extension_id() - return SharedEphemeralStateAccessor(extension_id) - - -__all__ = [ - "DEFAULT_TTL", - "EphemeralStateImpl", - "SharedEphemeralStateAccessor", -] + #: Shared (global) ephemeral state accessor. + #: Data stored via this accessor is visible to all users of the extension. + #: WARNING: Do not store user-specific or sensitive data here. + shared: SharedEphemeralStateAccessor = SharedEphemeralStateAccessor()