diff --git a/docs/developer_docs/extensions/architecture.md b/docs/developer_docs/extensions/architecture.md index 2dd642fd997..2ab340242e9 100644 --- a/docs/developer_docs/extensions/architecture.md +++ b/docs/developer_docs/extensions/architecture.md @@ -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. diff --git a/docs/developer_docs/extensions/overview.md b/docs/developer_docs/extensions/overview.md index 74ee7ac98c3..863dbbbef72 100644 --- a/docs/developer_docs/extensions/overview.md +++ b/docs/developer_docs/extensions/overview.md @@ -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 diff --git a/docs/developer_docs/extensions/quick-start.md b/docs/developer_docs/extensions/quick-start.md index f2c4388b8a5..6ac33592e6b 100644 --- a/docs/developer_docs/extensions/quick-start.md +++ b/docs/developer_docs/extensions/quick-start.md @@ -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: [ diff --git a/docs/developer_docs/extensions/storage.md b/docs/developer_docs/extensions/storage.md new file mode 100644 index 00000000000..2c48a154e4b --- /dev/null +++ b/docs/developer_docs/extensions/storage.md @@ -0,0 +1,221 @@ +--- +title: Storage +sidebar_position: 8 +--- + + + +# 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. diff --git a/docs/sidebarTutorials.js b/docs/sidebarTutorials.js index 807b1976091..6753a7d070e 100644 --- a/docs/sidebarTutorials.js +++ b/docs/sidebarTutorials.js @@ -88,6 +88,7 @@ const sidebars = { 'extensions/deployment', 'extensions/mcp', 'extensions/security', + 'extensions/storage', 'extensions/tasks', 'extensions/registry', ], diff --git a/superset-core/src/superset_core/extensions/storage/__init__.py b/superset-core/src/superset_core/extensions/storage/__init__.py new file mode 100644 index 00000000000..2bc122a2cca --- /dev/null +++ b/superset-core/src/superset_core/extensions/storage/__init__.py @@ -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", +] diff --git a/superset-core/src/superset_core/extensions/storage/ephemeral_state.py b/superset-core/src/superset_core/extensions/storage/ephemeral_state.py new file mode 100644 index 00000000000..1138a50b71c --- /dev/null +++ b/superset-core/src/superset_core/extensions/storage/ephemeral_state.py @@ -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", +] diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 index 93fc61459fb..d8b8f797da1 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 +++ b/superset-extensions-cli/src/superset_extensions_cli/templates/frontend/webpack.config.js.j2 @@ -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: [ diff --git a/superset-frontend/packages/superset-core/README.md b/superset-frontend/packages/superset-core/README.md index d9f835dab98..ed55c7ddf30 100644 --- a/superset-frontend/packages/superset-core/README.md +++ b/superset-frontend/packages/superset-core/README.md @@ -45,6 +45,7 @@ src/ ├── extensions/ ├── menus/ ├── sqlLab/ +├── storage/ ├── theme/ ├── translation/ ├── utils/ diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index ced523764ce..3ed3df47d94 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -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": [ diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 75863372409..4f5dbe70906 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -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'; diff --git a/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts b/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts new file mode 100644 index 00000000000..d9308fa33bf --- /dev/null +++ b/superset-frontend/packages/superset-core/src/storage/ephemeralState.ts @@ -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; + + /** + * 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; + + /** + * Remove a value from scoped ephemeral state. + * + * @param key The key to remove. + */ + remove(key: string): Promise; +} + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; diff --git a/superset-frontend/packages/superset-core/src/storage/index.ts b/superset-frontend/packages/superset-core/src/storage/index.ts new file mode 100644 index 00000000000..6f12608b2e3 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/storage/index.ts @@ -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'; diff --git a/superset-frontend/packages/superset-core/src/storage/localState.ts b/superset-frontend/packages/superset-core/src/storage/localState.ts new file mode 100644 index 00000000000..a8590cf1ec0 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/storage/localState.ts @@ -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; + + /** + * 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; + + /** + * Remove a value from scoped local state. + * + * @param key The key to remove. + */ + remove(key: string): Promise; +} + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; diff --git a/superset-frontend/packages/superset-core/src/storage/sessionState.ts b/superset-frontend/packages/superset-core/src/storage/sessionState.ts new file mode 100644 index 00000000000..e64b96d12ae --- /dev/null +++ b/superset-frontend/packages/superset-core/src/storage/sessionState.ts @@ -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; + + /** + * 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; + + /** + * Remove a value from scoped session state. + * + * @param key The key to remove. + */ + remove(key: string): Promise; +} + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; diff --git a/superset-frontend/packages/superset-core/src/storage/types.ts b/superset-frontend/packages/superset-core/src/storage/types.ts new file mode 100644 index 00000000000..4fafc491ebf --- /dev/null +++ b/superset-frontend/packages/superset-core/src/storage/types.ts @@ -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; + + /** + * 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, + ): Promise; + + /** + * Remove a value from storage. + * + * @param key The key to remove. + */ + remove(key: string): Promise; +} + +/** + * 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; +} diff --git a/superset-frontend/src/components/PanelToolbar/index.tsx b/superset-frontend/src/components/PanelToolbar/index.tsx index 8f1dcd93705..bc89b5db2b8 100644 --- a/superset-frontend/src/components/PanelToolbar/index.tsx +++ b/superset-frontend/src/components/PanelToolbar/index.tsx @@ -55,7 +55,7 @@ const PanelToolbar = ({ return (