mirror of
https://github.com/apache/superset.git
synced 2026-05-29 11:45:16 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
221
docs/developer_docs/extensions/storage.md
Normal file
221
docs/developer_docs/extensions/storage.md
Normal 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.
|
||||
@@ -88,6 +88,7 @@ const sidebars = {
|
||||
'extensions/deployment',
|
||||
'extensions/mcp',
|
||||
'extensions/security',
|
||||
'extensions/storage',
|
||||
'extensions/tasks',
|
||||
'extensions/registry',
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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: [
|
||||
|
||||
@@ -45,6 +45,7 @@ src/
|
||||
├── extensions/
|
||||
├── menus/
|
||||
├── sqlLab/
|
||||
├── storage/
|
||||
├── theme/
|
||||
├── translation/
|
||||
├── utils/
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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';
|
||||
|
||||
199
superset-frontend/src/core/storage/ephemeralState.ts
Normal file
199
superset-frontend/src/core/storage/ephemeralState.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
59
superset-frontend/src/core/storage/index.ts
Normal file
59
superset-frontend/src/core/storage/index.ts
Normal 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,
|
||||
};
|
||||
147
superset-frontend/src/core/storage/localState.ts
Normal file
147
superset-frontend/src/core/storage/localState.ts
Normal 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);
|
||||
30
superset-frontend/src/core/storage/sessionState.ts
Normal file
30
superset-frontend/src/core/storage/sessionState.ts
Normal 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);
|
||||
72
superset-frontend/src/core/storage/utils.ts
Normal file
72
superset-frontend/src/core/storage/utils.ts
Normal 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(':');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
47
superset-frontend/src/extensions/types.ts
Normal file
47
superset-frontend/src/extensions/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
33
superset/extensions/storage/__init__.py
Normal file
33
superset/extensions/storage/__init__.py
Normal 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",
|
||||
]
|
||||
372
superset/extensions/storage/api.py
Normal file
372
superset/extensions/storage/api.py
Normal 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")
|
||||
192
superset/extensions/storage/ephemeral_state.py
Normal file
192
superset/extensions/storage/ephemeral_state.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user