Use explicit context API

This commit is contained in:
Michael S. Molina
2026-04-08 10:43:49 -03:00
parent 853a6b10e7
commit c5532c8229
20 changed files with 554 additions and 338 deletions

View File

@@ -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

View File

@@ -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."
)

View File

@@ -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

View File

@@ -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__ = [

View File

@@ -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`.

View File

@@ -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<void>;
/**
* 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;

View File

@@ -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] });
* ```
*/

View File

@@ -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<void>;
export declare function remove(key: string): Promise<void>;
/**
* 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;

View File

@@ -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<void>;
export declare function remove(key: string): Promise<void>;
/**
* 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;

View File

@@ -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;
}

View File

@@ -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<T>(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<T>(
ctx: ExtensionContext,
fn: () => Promise<T>,
): Promise<T> {
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,
};

View File

@@ -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<StorageTypes.JsonValue | null> {
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<void> {
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<void> {
const url = buildEphemeralStateUrl(extensionId, key, true);
await SupersetClient.delete({ endpoint: url });
}
}
return {
DEFAULT_TTL,
async get(key: string): Promise<StorageTypes.JsonValue | null> {
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<void> {
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<void> {
const url = buildEphemeralStateUrl(extensionId, key, false);
await SupersetClient.delete({ endpoint: url });
},
shared(): StorageTypes.ephemeralState.EphemeralStateAccessor {
return new BoundSharedEphemeralAccessor();
},
};
}

View File

@@ -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,

View File

@@ -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<StorageTypes.JsonValue | null> {
const storageKey = buildKey(extensionId, key);
const value = browserStorage.getItem(storageKey);
return value ? JSON.parse(value) : null;
}
async set(key: string, value: StorageTypes.JsonValue): Promise<void> {
const storageKey = buildKey(extensionId, key);
browserStorage.setItem(storageKey, JSON.stringify(value));
}
async remove(key: string): Promise<void> {
const storageKey = buildKey(extensionId, key);
browserStorage.removeItem(storageKey);
}
}
return {
async get(key: string): Promise<StorageTypes.JsonValue | null> {
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<void> {
const userId = getCurrentUserId();
const storageKey = buildKey(extensionId, 'user', userId, key);
browserStorage.setItem(storageKey, JSON.stringify(value));
},
async remove(key: string): Promise<void> {
const userId = getCurrentUserId();
const storageKey = buildKey(extensionId, 'user', userId, key);
browserStorage.removeItem(storageKey);
},
shared(): StorageTypes.StorageAccessor {
return new BoundSharedAccessor();
},
};
}
/**
* Local state implementation using localStorage.
*/

View File

@@ -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;
}
/**

View File

@@ -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();
});
}
/**

View File

@@ -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;
};
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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()