feat(kernel): multi-instance support for store (#5083)

This commit is contained in:
Shreyas 2025-05-21 19:08:55 +05:30 committed by GitHub
parent 1da961cef7
commit d213bec3ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 129 additions and 79 deletions

View file

@ -7,40 +7,62 @@ import type {
import * as E from "fp-ts/Either"
import { getModule } from "."
const STORE_PATH = `${window.location.host}.hoppscotch.store`
export const Store = (() => {
const module = () => getModule("store")
return {
capabilities: () => module().capabilities,
init: () => module().init(),
set: (
init: async () => {
return module().init(STORE_PATH)
},
set: async (
namespace: string,
key: string,
value: unknown,
options?: StorageOptions
): Promise<E.Either<StoreError, void>> =>
module().set(namespace, key, value, options),
get: <T>(
): Promise<E.Either<StoreError, void>> => {
return module().set(STORE_PATH, namespace, key, value, options)
},
get: async <T>(
namespace: string,
key: string
): Promise<E.Either<StoreError, T | undefined>> =>
module().get<T>(namespace, key),
remove: (
): Promise<E.Either<StoreError, T | undefined>> => {
return module().get<T>(STORE_PATH, namespace, key)
},
remove: async (
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> =>
module().remove(namespace, key),
clear: (namespace?: string): Promise<E.Either<StoreError, void>> =>
module().clear(namespace),
has: (
): Promise<E.Either<StoreError, boolean>> => {
return module().remove(STORE_PATH, namespace, key)
},
clear: async (namespace?: string): Promise<E.Either<StoreError, void>> => {
return module().clear(STORE_PATH, namespace)
},
has: async (
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> => module().has(namespace, key),
listNamespaces: (): Promise<E.Either<StoreError, string[]>> =>
module().listNamespaces(),
listKeys: (namespace: string): Promise<E.Either<StoreError, string[]>> =>
module().listKeys(namespace),
watch: (namespace: string, key: string): StoreEventEmitter<StoreEvents> =>
module().watch(namespace, key),
): Promise<E.Either<StoreError, boolean>> => {
return module().has(STORE_PATH, namespace, key)
},
listNamespaces: async (): Promise<E.Either<StoreError, string[]>> => {
return module().listNamespaces(STORE_PATH)
},
listKeys: async (namespace: string): Promise<E.Either<StoreError, string[]>> => {
return module().listKeys(STORE_PATH, namespace)
},
watch: async (namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> => {
return module().watch(STORE_PATH, namespace, key)
},
} as const
})()

View file

@ -147,7 +147,7 @@ export class AgentKernelInterceptorService
...effectiveRequest.headers,
"User-Agent": existingUserAgentHeader
? effectiveRequest.headers[existingUserAgentHeader]
: "HoppscotchKernel/0.1.0",
: "HoppscotchKernel/0.2.0",
},
}

View file

@ -90,8 +90,9 @@ export class KernelInterceptorAgentStore extends Service {
}
}
private setupWatchers() {
Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS).on(
private async setupWatchers() {
const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS)
watcher.on(
"change",
async ({ value }) => {
if (value) {

View file

@ -88,7 +88,8 @@ export class KernelInterceptorExtensionStore extends Service {
this.setupExtensionStatusListener()
}
Store.watch(STORE_NAMESPACE, SETTINGS_KEY).on(
const watcher = await Store.watch(STORE_NAMESPACE, SETTINGS_KEY)
watcher.on(
"change",
async ({ value }) => {
if (value) {

View file

@ -207,7 +207,7 @@ export class NativeKernelInterceptorService
...effectiveRequest.headers,
"User-Agent": existingUserAgentHeader
? effectiveRequest.headers[existingUserAgentHeader]
: "HoppscotchKernel/0.1.0",
: "HoppscotchKernel/0.2.0",
},
}

View file

@ -72,8 +72,9 @@ export class KernelInterceptorNativeStore extends Service {
}
}
private setupWatchers() {
Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS).on(
private async setupWatchers() {
const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS)
watcher.on(
"change",
async ({ value }) => {
if (value) {

View file

@ -38,7 +38,8 @@ export class KernelInterceptorProxyStore extends Service {
await this.loadSettings()
Store.watch(STORE_NAMESPACE, SETTINGS_KEY).on(
const watcher = await Store.watch(STORE_NAMESPACE, SETTINGS_KEY)
watcher.on(
"change",
async ({ value }) => {
if (value) {

View file

@ -42,7 +42,7 @@ Cross-platform persistence with encryption support:
interface StoreV1 {
readonly capabilities: Set<StoreCapability>
set(namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<Either<StoreError, void>>
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents>
watch(namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>>
}
```
@ -86,7 +86,8 @@ await kernel.store.set("collections", "team-a", data, {
})
// Watch for changes
kernel.store.watch("collections", "team-a").on("change",
const watcher = await kernel.store.watch("collections", "team-a")
watcher.on("change",
(update) => console.log("Collection updated:", update)
)
```

View file

@ -1,6 +1,6 @@
{
"name": "@hoppscotch/kernel",
"version": "0.1.0",
"version": "0.2.0",
"description": "Cross-platform runtime kernel for Hoppscotch platform-ops",
"type": "module",
"main": "dist/hoppscotch-kernel.cjs",

View file

@ -13,28 +13,47 @@ import {
StoreEventEmitter,
} from '@store/v/1';
const STORE_PATH = `${window.location.host}.hoppscotch.store`
type NamespacedData = Record<string, Record<string, StoredData>>;
class TauriStoreManager {
private static instance: TauriStoreManager;
private static instances: Map<string, TauriStoreManager> = new Map();
private store: Store | null = null;
private listeners = new Map<string, Set<(payload: StoreEvents['change']) => void>>();
private data: NamespacedData = {};
private storePath: string;
private constructor() {}
private constructor(storePath: string) {
this.storePath = storePath;
}
static new(): TauriStoreManager {
if (!TauriStoreManager.instance) {
TauriStoreManager.instance = new TauriStoreManager();
static new(storePath: string): TauriStoreManager {
if (TauriStoreManager.instances.has(storePath)) {
return TauriStoreManager.instances.get(storePath)!;
}
const instance = new TauriStoreManager(storePath);
TauriStoreManager.instances.set(storePath, instance);
return instance;
}
static async closeAll(): Promise<void> {
const closePromises = Array.from(TauriStoreManager.instances.values())
.map(instance => instance.close());
await Promise.all(closePromises);
TauriStoreManager.instances.clear();
}
static async closeStore(storePath: string): Promise<void> {
const instance = TauriStoreManager.instances.get(storePath);
if (instance) {
await instance.close();
TauriStoreManager.instances.delete(storePath);
}
return TauriStoreManager.instance;
}
async init(): Promise<void> {
if (!this.store) {
this.store = await Store.load(STORE_PATH);
this.store = await Store.load(this.storePath);
const loadedData = await this.store.get<NamespacedData>('data');
this.data = loadedData ?? {};
@ -123,7 +142,7 @@ class TauriStoreManager {
return Object.keys(this.data[namespace] || {});
}
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents> {
async watch(namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const watchKey = `${namespace}:${key}`;
return {
on: <K extends keyof StoreEvents>(
@ -172,6 +191,7 @@ class TauriStoreManager {
this.store = null;
this.data = {};
this.listeners.clear();
TauriStoreManager.instances.delete(this.storePath);
}
}
}
@ -182,9 +202,9 @@ export const implementation: VersionedAPI<StoreV1> = {
id: 'tauri-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace', 'secure']),
async init() {
async init(storePath: string) {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
await manager.init();
return E.right(undefined);
} catch (error) {
@ -196,9 +216,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async set(namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>> {
async set(storePath: string, namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
const existingData = await manager.getRaw(namespace, key);
const createdAt = existingData?.metadata.createdAt || new Date().toISOString()
const updatedAt = new Date().toISOString()
@ -227,9 +247,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async get<T>(namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>> {
async get<T>(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.get<T>(namespace, key));
} catch (error) {
return E.left({
@ -240,9 +260,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async has(namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
async has(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.has(namespace, key));
} catch (error) {
return E.left({
@ -253,9 +273,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async remove(namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
async remove(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.delete(namespace, key));
} catch (error) {
return E.left({
@ -266,9 +286,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async clear(namespace?: string): Promise<E.Either<StoreError, void>> {
async clear(storePath: string, namespace?: string): Promise<E.Either<StoreError, void>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
await manager.clear(namespace);
return E.right(undefined);
} catch (error) {
@ -280,9 +300,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async listNamespaces(): Promise<E.Either<StoreError, string[]>> {
async listNamespaces(storePath: string): Promise<E.Either<StoreError, string[]>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listNamespaces());
} catch (error) {
return E.left({
@ -293,9 +313,9 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async listKeys(namespace: string): Promise<E.Either<StoreError, string[]>> {
async listKeys(storePath: string, namespace: string): Promise<E.Either<StoreError, string[]>> {
try {
const manager = TauriStoreManager.new();
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listKeys(namespace));
} catch (error) {
return E.left({
@ -306,8 +326,8 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents> {
const manager = TauriStoreManager.new();
async watch(storePath: string, namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const manager = TauriStoreManager.new(storePath);
return manager.watch(namespace, key);
},
},

View file

@ -91,7 +91,7 @@ class BrowserStoreManager {
.map(key => key.replace(`${namespace}:`, ''));
}
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents> {
async watch(namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const fullKey = this.getFullKey(namespace, key);
return {
on: (event, handler) => {
@ -129,7 +129,10 @@ export const implementation: VersionedAPI<StoreV1> = {
id: 'browser-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace']),
async init() {
// `init` and other methods in `web` don't `storePath`
// but having a consistent API where first param of every method
// is the path that filteres to the "realm" makes it easier to reason around
async init(_storePath) {
try {
return E.right(undefined);
} catch (e) {
@ -141,7 +144,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async set(namespace, key, value, options) {
async set(_storePath, namespace, key, value, options) {
try {
const manager = BrowserStoreManager.new();
const existingData = await manager.getRaw(namespace, key);
@ -172,7 +175,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async get(namespace, key) {
async get(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.get(namespace, key));
@ -185,7 +188,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async has(namespace, key) {
async has(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.has(namespace, key));
@ -198,7 +201,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async remove(namespace, key) {
async remove(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.delete(namespace, key));
@ -211,7 +214,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async clear(namespace) {
async clear(_storePath, namespace) {
try {
const manager = BrowserStoreManager.new();
await manager.clear(namespace);
@ -225,7 +228,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async listNamespaces() {
async listNamespaces(_storePath){
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.listNamespaces());
@ -238,7 +241,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
async listKeys(namespace) {
async listKeys(_storePath, namespace) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.listKeys(namespace));
@ -251,7 +254,7 @@ export const implementation: VersionedAPI<StoreV1> = {
}
},
watch(namespace, key) {
async watch(_storePath, namespace, key) {
const manager = BrowserStoreManager.new();
return manager.watch(namespace, key);
},

View file

@ -84,15 +84,15 @@ export interface StoreV1 {
readonly id: string
readonly capabilities: Set<StoreCapability>
init(): Promise<E.Either<StoreError, void>>
set(namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>>
get<T>(namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>>
remove(namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
clear(namespace?: string): Promise<E.Either<StoreError, void>>
has(namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
listNamespaces(): Promise<E.Either<StoreError, string[]>>
listKeys(namespace: string): Promise<E.Either<StoreError, string[]>>
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents>
init(storePath: string): Promise<E.Either<StoreError, void>>
set(storePath: string, namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>>
get<T>(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>>
remove(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
clear(storePath: string, namespace?: string): Promise<E.Either<StoreError, void>>
has(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
listNamespaces(storePath: string): Promise<E.Either<StoreError, string[]>>
listKeys(storePath: string, namespace: string): Promise<E.Either<StoreError, string[]>>
watch(storePath: string, namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>>
}
export const v1: VersionedAPI<StoreV1> = {
@ -109,7 +109,7 @@ export const v1: VersionedAPI<StoreV1> = {
has: async () => E.left({ kind: 'version', message: 'Not implemented' }),
listNamespaces: async () => E.left({ kind: 'version', message: 'Not implemented' }),
listKeys: async () => E.left({ kind: 'version', message: 'Not implemented' }),
watch: () => ({
watch: async () => ({
on: () => () => {},
once: () => () => {},
off: () => {}