feat(extensions): add Tier 1 and Tier 2 storage APIs for extensions

Implement managed storage APIs for extensions with automatic namespace
isolation. Storage is automatically bound to extensions before module
execution, ensuring data privacy between extensions.

- Tier 1 (localState/sessionState): Browser-based storage with user isolation
- Tier 2 (ephemeralState): Server-side cache with TTL support
- Update webpack externals to support subpath imports like @apache-superset/core/storage
- Add storage documentation and update architecture docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael S. Molina
2026-04-07 16:17:04 -03:00
parent d796543f5a
commit bfcc05a56f
33 changed files with 2304 additions and 29 deletions

View File

@@ -164,8 +164,13 @@ Extensions configure Webpack to expose their entry points:
```javascript
externalsType: 'window',
externals: {
'@apache-superset/core': 'superset',
externals: ({ request }, callback) => {
// Map @apache-superset/core and subpaths to window.superset
if (request?.startsWith('@apache-superset/core')) {
const parts = request.replace('@apache-superset/core', 'superset').split('/');
return callback(null, parts);
}
callback();
},
plugins: [
new ModuleFederationPlugin({
@@ -187,7 +192,7 @@ This configuration does several important things:
**`exposes`** - Declares which modules are available to the host application. Superset always loads extensions by requesting the `./index` module from the remote container — this is a fixed convention, not a configurable value. Extensions must expose exactly `'./index': './src/index.tsx'` and place all API registrations (views, commands, menus, editors, event listeners) in that file. The module is executed as a side effect when the extension loads, so any call to `views.registerView`, `commands.registerCommand`, etc. made at the top level of `index.tsx` will run automatically.
**`externals` and `externalsType`** - Tell Webpack that when the extension imports `@apache-superset/core`, it should use `window.superset` at runtime instead of bundling its own copy. This ensures extensions use the host's implementation of shared packages.
**`externals` and `externalsType`** - Tell Webpack that when the extension imports from `@apache-superset/core` or its subpaths (like `@apache-superset/core/storage`), it should resolve to `window.superset` or `window.superset.storage` at runtime. The function-based externals returns an array of path segments, which Webpack uses for nested property access.
**`shared`** - Prevents duplication of common libraries like React and Ant Design. The `singleton: true` setting ensures only one instance of each library exists, avoiding version conflicts and reducing bundle size.

View File

@@ -55,5 +55,6 @@ Extension developers have access to pre-built UI components via `@apache-superse
- **[Deployment](./deployment)** - Packaging and deploying extensions
- **[MCP Integration](./mcp)** - Adding AI agent capabilities using extensions
- **[Security](./security)** - Security considerations and best practices
- **[Storage](./storage)** - Managed storage API for persisting extension data
- **[Tasks](./tasks)** - Framework for creating and managing long running tasks
- **[Community Extensions](./registry)** - Browse extensions shared by the community

View File

@@ -223,7 +223,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
**`frontend/webpack.config.js`**
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and `externals` to map `@apache-superset/core` to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and a function-based `externals` to map `@apache-superset/core` and its subpaths (like `@apache-superset/core/storage`) to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
**Convention**: Superset always loads extensions by requesting the `./index` module from the Module Federation container. The `exposes` entry must be exactly `'./index': './src/index.tsx'` — do not rename or add additional entries. All API registrations must be reachable from that file. See [Architecture](./architecture#module-federation) for a full explanation.
@@ -255,10 +255,14 @@ module.exports = (env, argv) => {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
// Map @apache-superset/core imports to window.superset at runtime
// Map @apache-superset/core and subpaths to window.superset at runtime
externalsType: 'window',
externals: {
'@apache-superset/core': 'superset',
externals: ({ request }, callback) => {
if (request?.startsWith('@apache-superset/core')) {
const parts = request.replace('@apache-superset/core', 'superset').split('/');
return callback(null, parts);
}
callback();
},
module: {
rules: [

View File

@@ -0,0 +1,221 @@
---
title: Storage
sidebar_position: 8
---
<!--
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.
-->
# Storage
Superset Extensions have access to a managed storage API for persisting data. The storage system provides multiple tiers with different persistence characteristics, allowing extensions to choose the right storage for their needs.
Each extension receives its own isolated storage namespace. When Superset loads your extension, it binds storage to your extension's unique identifier, ensuring data privacy—two extensions using the same key will never collide, and extensions cannot access each other's data.
## 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 1: Local State
Browser-based storage that persists on the user's device. Use this for UI state and settings that don't need to sync across devices.
### 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:
- **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.
- **Clean uninstall**: When an extension is uninstalled, all its data can be cleanly removed using prefix-based deletion.
- **Future sandboxing**: The async API is designed for a future sandboxed execution model where extensions run in isolated contexts without direct DOM access.
- **Consistent patterns**: The same API shape works across all storage tiers, making it easy to switch between them.
### localState
Data persists across browser sessions until explicitly deleted or the user clears browser storage.
```typescript
import { localState } from '@apache-superset/core/storage';
// Save sidebar state
await localState.set('sidebar_collapsed', true);
// Retrieve it later
const isCollapsed = await localState.get('sidebar_collapsed');
// Remove it
await localState.remove('sidebar_collapsed');
```
### sessionState
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';
// Save wizard progress (lost when tab closes)
await sessionState.set('wizard_step', 3);
await sessionState.set('unsaved_form', { name: 'Draft' });
// Retrieve on page reload within same tab
const step = await sessionState.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.
```typescript
import { localState } from '@apache-superset/core/storage';
// Shared across all users on this device
await localState.shared().set('device_id', 'abc-123');
const deviceId = await localState.shared().get('device_id');
```
### When to Use Tier 1
- UI state (sidebar collapsed, panel sizes)
- Recently used items
- Draft form values
- Any data acceptable to lose if user clears browser
### Limitations
- Per-browser, per-device (not shared across devices)
- Subject to browser storage quotas (~5-10 MB)
- Not accessible from backend code
## Tier 2: Ephemeral State
Server-side cache storage with automatic TTL expiration. Use for temporary data that needs to be shared between frontend and backend, or persist across page reloads.
### Frontend Usage
```typescript
import { ephemeralState } from '@apache-superset/core/storage';
// Store with default TTL (1 hour)
await ephemeralState.set('job_progress', { pct: 42, status: 'running' });
// Store with custom TTL (5 minutes)
await ephemeralState.set('quick_cache', { results: [1, 2, 3] }, { ttl: 300 });
// Retrieve
const progress = await ephemeralState.get('job_progress');
// Remove
await ephemeralState.remove('job_progress');
```
### Backend Usage
```python
from superset_core.extensions.storage import ephemeral_state
# Store job progress
ephemeral_state.set('job_progress', {'pct': 42, 'status': 'running'}, ttl=3600)
# Retrieve
progress = ephemeral_state.get('job_progress')
# Remove
ephemeral_state.remove('job_progress')
```
### Shared State
For data that needs to be visible to all users:
```typescript
import { ephemeralState } from '@apache-superset/core/storage';
await ephemeralState.shared().set('shared_result', { data: [1, 2, 3] });
const result = await ephemeralState.shared().get('shared_result');
```
```python
from superset_core.extensions.storage import ephemeral_state
ephemeral_state.shared().set('shared_result', {'data': [1, 2, 3]})
result = ephemeral_state.shared().get('shared_result')
```
### When to Use Tier 2
- Background job progress indicators
- Cross-request intermediate state
- Query result previews
- Temporary computation results
- Any data that can be recomputed if lost
### Limitations
- Not guaranteed to survive server restarts
- Subject to cache eviction under memory pressure
- TTL-based expiration (data disappears after timeout)
## Tier 3: Persistent State
Coming soon.
## Choosing the Right Tier
| 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` |
## Key Patterns
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}` |
This ensures:
- Extensions cannot accidentally access each other's data
- Users cannot see other users' data (by default)
- Clean prefix-based deletion on uninstall
## Configuration
Administrators can configure Tier 2 storage in `superset_config.py`:
```python
EXTENSIONS_STORAGE = {
"EPHEMERAL": {
# Use Redis for better performance in production
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_URL": "redis://localhost:6379/2",
"CACHE_DEFAULT_TIMEOUT": 3600, # 1 hour default TTL
},
}
```
For development, the default `SupersetMetastoreCache` stores data in the metadata database.

View File

@@ -88,6 +88,7 @@ const sidebars = {
'extensions/deployment',
'extensions/mcp',
'extensions/security',
'extensions/storage',
'extensions/tasks',
'extensions/registry',
],

View File

@@ -0,0 +1,67 @@
# 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.
"""
Storage API for superset-core extensions.
Provides storage tiers for extensions with different persistence characteristics:
Tier 1 - Local State (Frontend Only):
- localState: Browser localStorage - persists across sessions
- sessionState: Browser sessionStorage - cleared on tab close
These are frontend-only and cannot be imported in backend code.
Tier 2 - Ephemeral State (Server Cache):
- ephemeral_state: Short-lived KV storage backed by server-side cache
- Supports TTL, not guaranteed to survive server restarts
- Use for temporary state like job progress or intermediate results
Tier 3 - Persistent State (Database) [Future]:
- persistent_state: Durable KV storage backed by database table
- Survives server restarts, supports encryption and resource linking
- Use for user preferences, extension config, per-resource settings
All tiers follow the same API pattern:
- User-scoped by default (private to current user)
- shared() accessor for data visible to all users
Usage:
from superset_core.extensions.storage import ephemeral_state
# User-scoped state (default - private to current user)
ephemeral_state.get('preference')
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)
# Future: Persistent state
# from superset_core.extensions.storage import persistent_state
# persistent_state.get('config')
# persistent_state.for_resource('dashboard', uuid).get('settings')
"""
from superset_core.extensions.storage import ephemeral_state
# Future: Tier 3
# from superset_core.extensions.storage import persistent_state
__all__ = [
"ephemeral_state",
# Future: "persistent_state",
]

View File

@@ -0,0 +1,141 @@
# 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.
"""
Ephemeral State API for superset-core extensions (Tier 2 Storage).
Provides short-lived KV storage backed by the configured server-side cache
backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
Not guaranteed to survive server restarts.
Host implementations will replace these functions during initialization
with concrete implementations providing actual functionality.
Cache keys are namespaced automatically:
- User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
- Shared (global): superset-ext:{extension_id}:{key}
Usage:
from superset_core.extensions.storage import ephemeral_state
# User-scoped state (default - private to current user)
ephemeral_state.get('preference')
ephemeral_state.set('preference', 'compact', ttl=3600)
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')
"""
from typing import Any, Protocol
# Default TTL: 1 hour
DEFAULT_TTL = 3600
class EphemeralStateAccessor(Protocol):
"""Protocol for scoped ephemeral state access."""
def get(self, key: str) -> Any:
"""Get a value from ephemeral state."""
...
def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
"""Set a value in ephemeral state with TTL."""
...
def remove(self, key: str) -> None:
"""Remove a value from ephemeral state."""
...
def get(key: str) -> Any:
"""
Get a value from user-scoped ephemeral state.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to retrieve.
:returns: The stored value, or None if not found or expired.
"""
raise NotImplementedError("Function will be replaced during initialization")
def set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
"""
Set a value in user-scoped ephemeral state with TTL.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to store.
:param value: The value to store (must be JSON-serializable).
:param ttl: Time-to-live in seconds (default: 3600).
"""
raise NotImplementedError("Function will be replaced during initialization")
def remove(key: str) -> None:
"""
Remove a value from user-scoped ephemeral state.
Data is automatically scoped to the current authenticated user.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to remove.
"""
raise NotImplementedError("Function will be replaced during initialization")
def shared() -> EphemeralStateAccessor:
"""
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, such as
job progress indicators or shared computation results.
WARNING: Data stored via shared() is visible to all users of the extension.
Do not store user-specific or sensitive data here.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:returns: An accessor for shared ephemeral state.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"DEFAULT_TTL",
"EphemeralStateAccessor",
"get",
"set",
"remove",
"shared",
]

View File

@@ -26,8 +26,13 @@ module.exports = (env, argv) => {
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
externalsType: "window",
externals: {
"@apache-superset/core": "superset",
externals: ({ request }, callback) => {
// Map @apache-superset/core and subpaths to window.superset
if (request?.startsWith("@apache-superset/core")) {
const parts = request.replace("@apache-superset/core", "superset").split("/");
return callback(null, parts);
}
callback();
},
module: {
rules: [

View File

@@ -45,6 +45,7 @@ src/
├── extensions/
├── menus/
├── sqlLab/
├── storage/
├── theme/
├── translation/
├── utils/

View File

@@ -65,6 +65,10 @@
"./testing": {
"types": "./lib/testing.d.ts",
"default": "./lib/testing.js"
},
"./storage": {
"types": "./lib/storage/index.d.ts",
"default": "./lib/storage/index.js"
}
},
"files": [

View File

@@ -23,6 +23,7 @@ export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as sqlLab from './sqlLab';
export * as storage from './storage';
export * as views from './views';
export * as contributions from './contributions';
export * as theme from './theme';

View File

@@ -0,0 +1,175 @@
/**
* 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.
*/
/**
* @fileoverview Ephemeral State API for Superset extensions (Tier 2 Storage).
*
* Provides short-lived KV storage backed by the configured server-side cache
* backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
* 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.
*
* Cache keys are namespaced automatically:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared (global): superset-ext:{extension_id}:{key}
*
* @example
* ```typescript
* import { ephemeralState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user)
* const progress = await ephemeralState.get('job_progress');
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 3600 });
* 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');
* ```
*/
/**
* Default TTL in seconds (1 hour).
*/
export const DEFAULT_TTL = 3600;
/**
* Options for setting ephemeral state values.
*/
export interface SetOptions {
/**
* Time-to-live in seconds. Defaults to 3600 (1 hour).
*/
ttl?: number;
}
/**
* Interface for scoped ephemeral state access.
* Returned by `shared()` for shared (global) operations.
*/
export interface EphemeralStateAccessor {
/**
* Get a value from scoped ephemeral state.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found or expired.
*/
get(key: string): Promise<unknown>;
/**
* Set a value in scoped ephemeral state with TTL.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings including TTL.
*/
set(key: string, value: unknown, options?: SetOptions): Promise<void>;
/**
* Remove a value from scoped ephemeral state.
*
* @param key The key to remove.
*/
remove(key: string): Promise<void>;
}
/**
* Get a value from user-scoped ephemeral state.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found or expired.
*
* @example
* ```typescript
* const progress = await ephemeralState.get('job_progress');
* if (progress !== null) {
* updateProgressBar(progress.pct);
* }
* ```
*/
export declare function get(key: string): Promise<unknown>;
/**
* Set a value in user-scoped ephemeral state with TTL.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings including TTL (default: 3600 seconds).
*
* @example
* ```typescript
* // Store with default TTL (1 hour)
* await ephemeralState.set('recent_items', ['item1', 'item2']);
*
* // Store with custom TTL (5 minutes)
* await ephemeralState.set('temp_selection', data, { ttl: 300 });
* ```
*/
export declare function set(
key: string,
value: unknown,
options?: SetOptions,
): Promise<void>;
/**
* Remove a value from user-scoped ephemeral state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await ephemeralState.remove('recent_items');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* 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, such as
* job progress indicators or shared computation results.
*
* 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');
*
* // Update shared job progress
* await ephemeralState.shared().set('computation_progress', { pct: 75 });
*
* // Clear shared state
* await ephemeralState.shared().remove('computation_progress');
* ```
*/
export declare function shared(): EphemeralStateAccessor;

View File

@@ -0,0 +1,59 @@
/**
* 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.
*/
/**
* @fileoverview Storage API for Superset extensions.
*
* This module provides storage tiers for extensions:
*
* - **localState** (Tier 1): Browser localStorage - persists across sessions
* - **sessionState** (Tier 1): Browser sessionStorage - cleared on tab close
* - **ephemeralState** (Tier 2): Server-side cache with TTL - short-lived
* - **persistentState** (Tier 3): Database storage - durable [future]
*
* All tiers follow the same API pattern:
* - User-scoped by default (private to current user)
* - `shared()` accessor for data visible to all users
*
* @example
* ```typescript
* import { localState, sessionState, ephemeralState } from '@apache-superset/core/storage';
*
* // Tier 1 - localStorage (persists across browser sessions)
* await localState.set('sidebar_collapsed', true);
* const isCollapsed = await localState.get('sidebar_collapsed');
*
* // Tier 1 - sessionStorage (cleared on tab close)
* await sessionState.set('wizard_step', 3);
* const step = await sessionState.get('wizard_step');
*
* // Tier 2 - Server cache (short-lived, with TTL)
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 300 });
* 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] });
* ```
*/
export * as localState from './localState';
export * as sessionState from './sessionState';
export * as ephemeralState from './ephemeralState';
export * from './types';

View File

@@ -0,0 +1,150 @@
/**
* 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.
*/
/**
* @fileoverview Local State API for Superset extensions (Tier 1 Storage).
*
* Provides client-side KV storage backed by the browser's localStorage.
* Data persists across browser sessions but is per-device (not shared across
* 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.
*
* Key patterns:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared: superset-ext:{extension_id}:{key}
*
* The API is async to maintain compatibility with a future sandboxed execution
* model where storage calls would go through a postMessage bridge.
*
* @example
* ```typescript
* import { localState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user)
* const isCollapsed = await localState.get('sidebar_collapsed');
* await localState.set('sidebar_collapsed', true);
* 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');
* ```
*/
/**
* Interface for scoped local state access.
* Returned by `shared()` for shared operations.
*/
export interface LocalStateAccessor {
/**
* Get a value from scoped local state.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*/
get(key: string): Promise<unknown>;
/**
* Set a value in scoped local state.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*/
set(key: string, value: unknown): Promise<void>;
/**
* Remove a value from scoped local state.
*
* @param key The key to remove.
*/
remove(key: string): Promise<void>;
}
/**
* Get a value from user-scoped local state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser cannot see or modify this data.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*
* @example
* ```typescript
* const isCollapsed = await localState.get('sidebar_collapsed');
* if (isCollapsed) {
* collapseSidebar();
* }
* ```
*/
export declare function get(key: string): Promise<unknown>;
/**
* Set a value in user-scoped local state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser cannot see or modify this data.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*
* @example
* ```typescript
* await localState.set('sidebar_collapsed', true);
* await localState.set('panel_width', 300);
* ```
*/
export declare function set(key: string, value: unknown): Promise<void>;
/**
* Remove a value from user-scoped local state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await localState.remove('sidebar_collapsed');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Get a shared local state accessor.
*
* Returns an 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.
* 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');
*
* // Set device-specific setting
* await localState.shared().set('last_used_printer', 'HP-1234');
* ```
*/
export declare function shared(): LocalStateAccessor;

View File

@@ -0,0 +1,151 @@
/**
* 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.
*/
/**
* @fileoverview Session State API for Superset extensions (Tier 1 Storage).
*
* Provides client-side KV storage backed by the browser's sessionStorage.
* Data is cleared when the browser tab/window is closed. Use this for
* 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.
*
* Key patterns:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared: superset-ext:{extension_id}:{key}
*
* The API is async to maintain compatibility with a future sandboxed execution
* model where storage calls would go through a postMessage bridge.
*
* @example
* ```typescript
* import { sessionState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user, cleared on tab close)
* const wizardStep = await sessionState.get('wizard_step');
* await sessionState.set('wizard_step', 3);
* 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 });
* ```
*/
/**
* Interface for scoped session state access.
* Returned by `shared()` for shared operations.
*/
export interface SessionStateAccessor {
/**
* Get a value from scoped session state.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*/
get(key: string): Promise<unknown>;
/**
* Set a value in scoped session state.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*/
set(key: string, value: unknown): Promise<void>;
/**
* Remove a value from scoped session state.
*
* @param key The key to remove.
*/
remove(key: string): Promise<void>;
}
/**
* Get a value from user-scoped session state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser tab cannot see or modify this data.
* Data is cleared when the tab/window is closed.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*
* @example
* ```typescript
* const wizardStep = await sessionState.get('wizard_step');
* if (wizardStep !== null) {
* resumeWizard(wizardStep);
* }
* ```
*/
export declare function get(key: string): Promise<unknown>;
/**
* Set a value in user-scoped session state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser tab cannot see or modify this data.
* Data is cleared when the tab/window is closed.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*
* @example
* ```typescript
* await sessionState.set('wizard_step', 3);
* await sessionState.set('unsaved_form', formData);
* ```
*/
export declare function set(key: string, value: unknown): Promise<void>;
/**
* Remove a value from user-scoped session state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await sessionState.remove('wizard_step');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Get a shared session state accessor.
*
* Returns an 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.
* 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);
*
* // Retrieve temporary shared data
* const result = await sessionState.shared().get('temp_computation');
* ```
*/
export declare function shared(): SessionStateAccessor;

View File

@@ -0,0 +1,73 @@
/**
* 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.
*/
/**
* @fileoverview Shared types for extension storage APIs.
*
* These types are shared across all storage tiers (local, session, ephemeral,
* persistent) to ensure a consistent API pattern.
*/
/**
* Base interface for a storage accessor.
* All storage tiers implement this interface for both user-scoped and shared access.
*/
export interface StorageAccessor {
/**
* Get a value from storage.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*/
get(key: string): Promise<unknown>;
/**
* Set a value in storage.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings (varies by tier).
*/
set(
key: string,
value: unknown,
options?: Record<string, unknown>,
): Promise<void>;
/**
* Remove a value from storage.
*
* @param key The key to remove.
*/
remove(key: string): Promise<void>;
}
/**
* Base interface for a storage tier.
* 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(): StorageAccessor;
}

View File

@@ -55,7 +55,7 @@ const PanelToolbar = ({
return (
<Button
key={item.view}
key={item.command}
onClick={() => commands.executeCommand(command?.id)}
tooltip={command?.description ?? command?.title}
icon={<Icon iconSize="m" />}

View File

@@ -33,5 +33,6 @@ export * from './extensions';
export * from './menus';
export * from './models';
export * from './sqlLab';
export * from './storage';
export * from './utils';
export * from './views';

View File

@@ -0,0 +1,199 @@
/**
* 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.
*/
/**
* Host implementation for Tier 2 Ephemeral State (Server Cache).
*/
import {
storage as storageApi,
type storage as StorageTypes,
} from '@apache-superset/core';
import { SupersetClient } from '@superset-ui/core';
import { DEFAULT_TTL, getCurrentExtensionId } from './utils';
/**
* Build the API URL for ephemeral state operations.
*/
function buildEphemeralStateUrl(
extensionId: string,
key: string,
isShared: boolean,
): string {
const basePath = '/api/v1/extensions/storage/ephemeral';
return isShared
? `${basePath}/shared/${extensionId}/${key}`
: `${basePath}/${extensionId}/${key}`;
}
/**
* Shared ephemeral state accessor implementation.
*/
class SharedEphemeralStateAccessor
implements StorageTypes.ephemeralState.EphemeralStateAccessor
{
private extensionId: string;
constructor(extensionId: string) {
this.extensionId = extensionId;
}
async get(key: string): Promise<unknown> {
const url = buildEphemeralStateUrl(this.extensionId, key, true);
const response = await SupersetClient.get({ endpoint: url });
return response.json?.result ?? null;
}
async set(
key: string,
value: unknown,
options?: StorageTypes.ephemeralState.SetOptions,
): Promise<void> {
const url = buildEphemeralStateUrl(this.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(this.extensionId, key, true);
await SupersetClient.delete({ endpoint: url });
}
}
/**
* Ephemeral state implementation using REST API.
* By default, all operations are user-scoped.
*/
export const ephemeralState: typeof storageApi.ephemeralState = {
DEFAULT_TTL,
async get(key: string): Promise<unknown> {
const extensionId = getCurrentExtensionId();
const url = buildEphemeralStateUrl(extensionId, key, false);
const response = await SupersetClient.get({ endpoint: url });
return response.json?.result ?? null;
},
async set(
key: string,
value: unknown,
options?: StorageTypes.ephemeralState.SetOptions,
): Promise<void> {
const extensionId = getCurrentExtensionId();
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 extensionId = getCurrentExtensionId();
const url = buildEphemeralStateUrl(extensionId, key, false);
await SupersetClient.delete({ endpoint: url });
},
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<unknown> {
const url = buildEphemeralStateUrl(extensionId, key, true);
const response = await SupersetClient.get({ endpoint: url });
return response.json?.result ?? null;
}
async set(
key: string,
value: unknown,
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<unknown> {
const url = buildEphemeralStateUrl(extensionId, key, false);
const response = await SupersetClient.get({ endpoint: url });
return response.json?.result ?? null;
},
async set(
key: string,
value: unknown,
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

@@ -0,0 +1,59 @@
/**
* 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.
*/
/**
* Host implementation for extension storage APIs.
*
* This module provides the concrete implementations that are exposed to
* extensions via Module Federation. Extensions import from @apache-superset/core
* and the host provides these implementations at runtime.
*
* All tiers follow the same pattern:
* - User-scoped by default (private to current user)
* - shared() accessor for data visible to all users
*/
import { storage as storageApi } from '@apache-superset/core';
import { localState, createBoundBrowserStorage } 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),
};
}
export const storage: typeof storageApi & {
forExtension: typeof forExtension;
} = {
localState,
sessionState,
ephemeralState,
forExtension,
};

View File

@@ -0,0 +1,147 @@
/**
* 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.
*/
/**
* Host implementation for Tier 1 Local State (localStorage).
*/
import {
storage as storageApi,
type storage as StorageTypes,
} from '@apache-superset/core';
import { buildKey, getCurrentExtensionId, getCurrentUserId } from './utils';
/**
* Create a browser storage implementation (localStorage or sessionStorage).
* Used for both localState and sessionState.
*/
export function createBrowserStorageImpl(
storage: Storage,
): typeof storageApi.localState {
class SharedAccessor implements StorageTypes.localState.LocalStateAccessor {
private extensionId: string;
constructor(extensionId: string) {
this.extensionId = extensionId;
}
async get(key: string): Promise<unknown> {
const storageKey = buildKey(this.extensionId, key);
const value = storage.getItem(storageKey);
return value ? JSON.parse(value) : null;
}
async set(key: string, value: unknown): Promise<void> {
const storageKey = buildKey(this.extensionId, key);
storage.setItem(storageKey, JSON.stringify(value));
}
async remove(key: string): Promise<void> {
const storageKey = buildKey(this.extensionId, key);
storage.removeItem(storageKey);
}
}
return {
async get(key: string): Promise<unknown> {
const extensionId = getCurrentExtensionId();
const userId = getCurrentUserId();
const storageKey = buildKey(extensionId, 'user', userId, key);
const value = storage.getItem(storageKey);
return value ? JSON.parse(value) : null;
},
async set(key: string, value: unknown): Promise<void> {
const extensionId = getCurrentExtensionId();
const userId = getCurrentUserId();
const storageKey = buildKey(extensionId, 'user', userId, key);
storage.setItem(storageKey, JSON.stringify(value));
},
async remove(key: string): Promise<void> {
const extensionId = getCurrentExtensionId();
const userId = getCurrentUserId();
const storageKey = buildKey(extensionId, 'user', userId, key);
storage.removeItem(storageKey);
},
shared(): StorageTypes.localState.LocalStateAccessor {
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.localState.LocalStateAccessor
{
async get(key: string): Promise<unknown> {
const storageKey = buildKey(extensionId, key);
const value = browserStorage.getItem(storageKey);
return value ? JSON.parse(value) : null;
}
async set(key: string, value: unknown): 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<unknown> {
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: unknown): 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.localState.LocalStateAccessor {
return new BoundSharedAccessor();
},
};
}
/**
* Local state implementation using localStorage.
*/
export const localState = createBrowserStorageImpl(localStorage);

View File

@@ -0,0 +1,30 @@
/**
* 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.
*/
/**
* Host implementation for Tier 1 Session State (sessionStorage).
*/
import { createBrowserStorageImpl } from './localState';
/**
* Session state implementation using sessionStorage.
* Cleared when the browser tab is closed.
*/
export const sessionState = createBrowserStorageImpl(sessionStorage);

View File

@@ -0,0 +1,72 @@
/**
* 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.
*/
/**
* Shared utilities for extension storage implementations.
*/
import getBootstrapData from 'src/utils/getBootstrapData';
// Key prefix for extension storage
export const KEY_PREFIX = 'superset-ext';
// Default TTL for ephemeral state: 1 hour
export const DEFAULT_TTL = 3600;
/**
* Get the current extension ID from context.
* This is injected by the extension loader when running extension code.
*/
export function getCurrentExtensionId(): string {
const extensionId = (
window as unknown as { __SUPERSET_EXTENSION_ID__?: string }
).__SUPERSET_EXTENSION_ID__;
if (!extensionId) {
throw new Error(
'Storage APIs can only be used within an extension context. ' +
'Ensure this code is being executed by an extension.',
);
}
return extensionId;
}
/**
* Get the current user ID from bootstrap data.
*/
export function getCurrentUserId(): number {
const bootstrapData = getBootstrapData();
const userId = bootstrapData?.user?.userId;
if (userId === undefined) {
throw new Error(
'Storage APIs require an authenticated user. ' +
'Ensure the user is logged in.',
);
}
return userId;
}
/**
* Build a storage key with the standard prefix.
*/
export function buildKey(
extensionId: string,
...parts: (string | number)[]
): string {
return [KEY_PREFIX, extensionId, ...parts].join(':');
}

View File

@@ -19,6 +19,8 @@
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import type { common as core } from '@apache-superset/core';
import { storage } from 'src/core';
import './types';
type Extension = core.Extension;
@@ -136,6 +138,12 @@ class ExtensionsLoader {
await container.init(__webpack_share_scopes__.default);
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 = storage.forExtension(id);
// Execute the module factory - side effects fire registrations
factory();
}

View File

@@ -28,26 +28,13 @@ import {
extensions,
menus,
sqlLab,
storage,
views,
} from 'src/core';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
import './types';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
@@ -77,6 +64,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
extensions,
menus,
sqlLab,
storage,
views,
};

View File

@@ -0,0 +1,47 @@
/**
* 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.
*/
import type { storage as storageTypes } from '@apache-superset/core';
import type {
authentication,
core,
commands,
editors,
extensions,
menus,
sqlLab,
views,
} from 'src/core';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
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;
views: typeof views;
};
}
}

View File

@@ -1148,6 +1148,35 @@ EXPLORE_FORM_DATA_CACHE_CONFIG: CacheConfig = {
"CODEC": JsonKeyValueCodec(),
}
# Extension storage configuration for all storage tiers.
# Extensions use these storage tiers for different persistence needs:
# - EPHEMERAL (Tier 2): Short-lived cache storage with TTL
# - PERSISTENT (Tier 3): Durable database storage [future]
EXTENSIONS_STORAGE: dict[str, Any] = {
# Tier 2: Ephemeral State - Server-side cache with TTL.
# Short-lived KV storage that automatically expires. Not guaranteed to
# survive server restarts. Use for temporary state like job progress,
# intermediate results, or cross-request state.
"EPHEMERAL": {
"CACHE_TYPE": "SupersetMetastoreCache",
"CACHE_DEFAULT_TIMEOUT": int(timedelta(hours=1).total_seconds()),
# Should the timeout be reset when retrieving a cached value?
"REFRESH_TIMEOUT_ON_RETRIEVAL": False,
# The following parameter only applies to `MetastoreCache`:
# How should entries be serialized/deserialized?
"CODEC": JsonKeyValueCodec(),
},
# Tier 3: Persistent State - Database storage [future]
# Durable KV storage backed by a dedicated database table.
# Survives server restarts. Supports encryption and resource linking.
# "PERSISTENT": {
# # Maximum storage quota per extension in bytes (default: 1MB)
# "QUOTA_PER_EXTENSION": 1024 * 1024,
# # Enable encryption at rest for sensitive data
# "ENCRYPTION_ENABLED": True,
# },
}
# store cache keys by datasource UID (via CacheKey) for custom processing/invalidation
STORE_CACHE_KEYS_IN_METADATA_DB = False

View File

@@ -229,6 +229,22 @@ def inject_model_session_implementation() -> None:
core_models_module.get_session = get_session
def inject_storage_implementations() -> None:
"""
Replace abstract storage functions in superset_core.extensions.storage with concrete
implementations from Superset.
"""
import superset_core.extensions.storage.ephemeral_state as core_ephemeral_state
from superset.extensions.storage.ephemeral_state import EphemeralStateImpl
# Replace abstract functions with concrete implementations
core_ephemeral_state.get = EphemeralStateImpl.get
core_ephemeral_state.set = EphemeralStateImpl.set
core_ephemeral_state.remove = EphemeralStateImpl.remove
core_ephemeral_state.shared = EphemeralStateImpl.shared
def initialize_core_api_dependencies() -> None:
"""
Initialize all dependency injections for the superset-core API.
@@ -242,3 +258,4 @@ def initialize_core_api_dependencies() -> None:
inject_query_implementations()
inject_task_implementations()
inject_rest_api_implementations()
inject_storage_implementations()

View File

@@ -0,0 +1,33 @@
# 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.
"""
Host implementations for extension storage APIs.
This module provides concrete implementations that are injected into
superset_core.extensions.storage at startup.
"""
from superset.extensions.storage.ephemeral_state import (
EphemeralStateImpl,
SharedEphemeralStateAccessor,
)
__all__ = [
"EphemeralStateImpl",
"SharedEphemeralStateAccessor",
]

View File

@@ -0,0 +1,372 @@
# 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.
"""
REST API for extension storage.
Provides HTTP endpoints for frontend extensions to access server-side
ephemeral storage without direct backend code.
By default, all operations are user-scoped (private to the current user).
Use the /shared/ endpoints to access state visible to all users.
"""
from __future__ import annotations
from typing import Any
from flask import g, request
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from superset.extensions import cache_manager
from superset.extensions.types import LoadedExtension
from superset.extensions.utils import get_extensions
# Key separator
SEPARATOR = ":"
# Key prefix for extension ephemeral state
KEY_PREFIX = "superset-ext"
# Default TTL: 1 hour
DEFAULT_TTL = 3600
def _build_cache_key(*parts: Any) -> str:
"""Build a namespaced cache key from parts."""
return SEPARATOR.join(str(part) for part in parts)
def _get_extension_or_404(extension_id: str) -> LoadedExtension | None:
"""Get extension by ID or return None if not found."""
extensions = get_extensions()
return extensions.get(extension_id)
class ExtensionStorageRestApi(BaseApi):
"""REST API for extension ephemeral state storage."""
allow_browser_login = True
route_base = "/api/v1/extensions/storage"
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
from flask import jsonify
return jsonify(kwargs), status_code
def response_404(self, message: str = "Not found") -> Response:
"""Helper method to create 404 responses."""
from flask import jsonify
return jsonify({"message": message}), 404
def response_400(self, message: str) -> Response:
"""Helper method to create 400 responses."""
from flask import jsonify
return jsonify({"message": message}), 400
# =========================================================================
# User-Scoped Ephemeral State Endpoints (Default)
# =========================================================================
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("GET",))
def get_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Get a value from user-scoped ephemeral state.
---
get:
summary: Get a value from user-scoped ephemeral state (default)
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
responses:
200:
description: Value retrieved successfully
content:
application/json:
schema:
type: object
properties:
result:
description: The stored value
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
user_id = g.user.id
cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
value = cache_manager.extension_ephemeral_state_cache.get(cache_key)
return self.response(200, result=value)
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("PUT",))
def set_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Set a value in user-scoped ephemeral state.
---
put:
summary: Set a value in user-scoped ephemeral state (default)
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
value:
description: The value to store
ttl:
type: integer
description: Time-to-live in seconds (default 3600)
responses:
200:
description: Value stored successfully
400:
description: Invalid request body
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
body = request.get_json(silent=True) or {}
if "value" not in body:
return self.response_400("Request body must contain 'value' field")
value = body["value"]
ttl = body.get("ttl", DEFAULT_TTL)
user_id = g.user.id
cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
return self.response(200, message="Value stored successfully")
@protect()
@safe
@expose("/ephemeral/<extension_id>/<key>", methods=("DELETE",))
def delete_ephemeral(self, extension_id: str, key: str, **kwargs: Any) -> Response:
"""Delete a value from user-scoped ephemeral state.
---
delete:
summary: Delete a value from user-scoped ephemeral state (default)
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
responses:
200:
description: Value deleted successfully
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
user_id = g.user.id
cache_key = _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
return self.response(200, message="Value deleted successfully")
# =========================================================================
# Shared (Global) Ephemeral State Endpoints
# =========================================================================
@protect()
@safe
@expose("/ephemeral/shared/<extension_id>/<key>", methods=("GET",))
def get_ephemeral_shared(
self, extension_id: str, key: str, **kwargs: Any
) -> Response:
"""Get a value from shared ephemeral state.
---
get:
summary: Get a value from shared (global) ephemeral state
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
responses:
200:
description: Value retrieved successfully
content:
application/json:
schema:
type: object
properties:
result:
description: The stored value
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
cache_key = _build_cache_key(KEY_PREFIX, extension_id, key)
value = cache_manager.extension_ephemeral_state_cache.get(cache_key)
return self.response(200, result=value)
@protect()
@safe
@expose("/ephemeral/shared/<extension_id>/<key>", methods=("PUT",))
def set_ephemeral_shared(
self, extension_id: str, key: str, **kwargs: Any
) -> Response:
"""Set a value in shared ephemeral state.
---
put:
summary: Set a value in shared (global) ephemeral state
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
value:
description: The value to store
ttl:
type: integer
description: Time-to-live in seconds (default 3600)
responses:
200:
description: Value stored successfully
400:
description: Invalid request body
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
body = request.get_json(silent=True) or {}
if "value" not in body:
return self.response_400("Request body must contain 'value' field")
value = body["value"]
ttl = body.get("ttl", DEFAULT_TTL)
cache_key = _build_cache_key(KEY_PREFIX, extension_id, key)
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
return self.response(200, message="Value stored successfully")
@protect()
@safe
@expose("/ephemeral/shared/<extension_id>/<key>", methods=("DELETE",))
def delete_ephemeral_shared(
self, extension_id: str, key: str, **kwargs: Any
) -> Response:
"""Delete a value from shared ephemeral state.
---
delete:
summary: Delete a value from shared (global) ephemeral state
parameters:
- in: path
name: extension_id
schema:
type: string
required: true
description: Extension ID (publisher.name)
- in: path
name: key
schema:
type: string
required: true
description: Storage key
responses:
200:
description: Value deleted successfully
404:
description: Extension not found
"""
extension = _get_extension_or_404(extension_id)
if not extension:
return self.response_404("Extension not found")
cache_key = _build_cache_key(KEY_PREFIX, extension_id, key)
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
return self.response(200, message="Value deleted successfully")

View File

@@ -0,0 +1,192 @@
# 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.
"""
Host implementation for Ephemeral State (Tier 2 Storage).
Provides the concrete cache-backed implementation that is injected into
superset_core.extensions.storage.ephemeral_state at startup.
"""
from __future__ import annotations
from typing import Any
from flask import g
from superset.extensions import cache_manager
from superset.extensions.context import get_current_extension_context
# Default TTL: 1 hour
DEFAULT_TTL = 3600
# Key separator
SEPARATOR = ":"
# Key prefix for extension ephemeral state
KEY_PREFIX = "superset-ext"
def _get_extension_id() -> str:
"""Get the current extension ID from context."""
context = get_current_extension_context()
if context is None:
raise RuntimeError(
"ephemeral_state can only be used within an extension context. "
"Ensure this code is being executed during extension loading or "
"within an extension API request handler."
)
return context.manifest.id
def _get_current_user_id() -> int:
"""Get the current authenticated user's ID."""
user = getattr(g, "user", None)
if user is None or not hasattr(user, "id"):
raise RuntimeError(
"ephemeral_state requires an authenticated user. "
"Ensure the request has been authenticated."
)
return user.id
def _build_cache_key(*parts: Any) -> str:
"""Build a namespaced cache key from parts."""
return SEPARATOR.join(str(part) for part in parts)
class SharedEphemeralStateAccessor:
"""
Accessor for shared (global) ephemeral state.
Data stored via this accessor is visible to all users of the extension.
"""
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, key)
def get(self, key: str) -> Any:
"""
Get a value from shared ephemeral state.
:param key: The key to retrieve.
:returns: The stored value, or None if not found or expired.
"""
cache_key = self._build_key(key)
return cache_manager.extension_ephemeral_state_cache.get(cache_key)
def set(self, key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
"""
Set a value in shared ephemeral state with TTL.
:param key: The key to store.
:param value: The value to store (must be JSON-serializable).
:param ttl: Time-to-live in seconds (default: 3600).
"""
cache_key = self._build_key(key)
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
def remove(self, key: str) -> None:
"""
Remove a value from shared ephemeral state.
:param key: The key to remove.
"""
cache_key = self._build_key(key)
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
class EphemeralStateImpl:
"""
Host implementation for ephemeral state operations.
This class provides the concrete implementation that is injected into
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.
"""
@staticmethod
def _build_user_key(extension_id: str, user_id: int, key: str) -> str:
"""Build a user-scoped cache key."""
return _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
@staticmethod
def get(key: str) -> Any:
"""
Get a value from user-scoped ephemeral state.
:param key: The key to retrieve.
:returns: The stored value, or None if not found or expired.
"""
extension_id = _get_extension_id()
user_id = _get_current_user_id()
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
return cache_manager.extension_ephemeral_state_cache.get(cache_key)
@staticmethod
def set(key: str, value: Any, ttl: int = DEFAULT_TTL) -> None:
"""
Set a value in user-scoped ephemeral state with TTL.
:param key: The key to store.
:param value: The value to store (must be JSON-serializable).
:param ttl: Time-to-live in seconds (default: 3600).
"""
extension_id = _get_extension_id()
user_id = _get_current_user_id()
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
@staticmethod
def remove(key: str) -> None:
"""
Remove a value from user-scoped ephemeral state.
:param key: The key to remove.
"""
extension_id = _get_extension_id()
user_id = _get_current_user_id()
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",
]

View File

@@ -275,8 +275,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
from superset.extensions.api import ExtensionsRestApi
from superset.extensions.storage.api import ExtensionStorageRestApi
appbuilder.add_api(ExtensionsRestApi)
appbuilder.add_api(ExtensionStorageRestApi)
if feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
from superset.tasks.api import TaskRestApi

View File

@@ -193,15 +193,25 @@ class CacheManager:
self._thumbnail_cache = SupersetCache()
self._filter_state_cache = SupersetCache()
self._explore_form_data_cache = ExploreFormDataCache()
self._extension_ephemeral_state_cache = SupersetCache()
self._distributed_coordination: (
RedisCacheBackend | RedisSentinelCacheBackend | None
) = None
@staticmethod
def _init_cache(
app: Flask, cache: Cache, cache_config_key: str, required: bool = False
app: Flask,
cache: Cache,
config: str | dict[str, Any],
required: bool = False,
) -> None:
cache_config = app.config[cache_config_key]
if isinstance(config, dict):
cache_config = config
config_name = cache_config.get("CACHE_KEY_PREFIX", "unknown")
else:
cache_config = app.config[config]
config_name = config
cache_type = cache_config.get("CACHE_TYPE")
if (required and cache_type is None) or cache_type == "SupersetMetastoreCache":
if cache_type is None and not app.debug:
@@ -210,10 +220,10 @@ class CacheManager:
"metadata database, for the following cache: `%s`. "
"It is recommended to use `RedisCache`, `MemcachedCache` or "
"another dedicated caching backend for production deployments",
cache_config_key,
config_name,
)
cache_type = CACHE_IMPORT_PATH
cache_key_prefix = cache_config.get("CACHE_KEY_PREFIX", cache_config_key)
cache_key_prefix = cache_config.get("CACHE_KEY_PREFIX", config_name)
cache_config.update(
{"CACHE_TYPE": cache_type, "CACHE_KEY_PREFIX": cache_key_prefix}
)
@@ -237,6 +247,12 @@ class CacheManager:
"EXPLORE_FORM_DATA_CACHE_CONFIG",
required=True,
)
self._init_cache(
app,
self._extension_ephemeral_state_cache,
app.config["EXTENSIONS_STORAGE"]["EPHEMERAL"],
required=True,
)
self._init_distributed_coordination(app)
def _init_distributed_coordination(self, app: Flask) -> None:
@@ -284,6 +300,10 @@ class CacheManager:
def explore_form_data_cache(self) -> Cache:
return self._explore_form_data_cache
@property
def extension_ephemeral_state_cache(self) -> Cache:
return self._extension_ephemeral_state_cache
@property
def distributed_coordination(
self,