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

View file

@ -147,7 +147,7 @@ export class AgentKernelInterceptorService
...effectiveRequest.headers, ...effectiveRequest.headers,
"User-Agent": existingUserAgentHeader "User-Agent": existingUserAgentHeader
? effectiveRequest.headers[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() { private async setupWatchers() {
Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS).on( const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS)
watcher.on(
"change", "change",
async ({ value }) => { async ({ value }) => {
if (value) { if (value) {

View file

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

View file

@ -207,7 +207,7 @@ export class NativeKernelInterceptorService
...effectiveRequest.headers, ...effectiveRequest.headers,
"User-Agent": existingUserAgentHeader "User-Agent": existingUserAgentHeader
? effectiveRequest.headers[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() { private async setupWatchers() {
Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS).on( const watcher = await Store.watch(STORE_NAMESPACE, STORE_KEYS.SETTINGS)
watcher.on(
"change", "change",
async ({ value }) => { async ({ value }) => {
if (value) { if (value) {

View file

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

View file

@ -42,7 +42,7 @@ Cross-platform persistence with encryption support:
interface StoreV1 { interface StoreV1 {
readonly capabilities: Set<StoreCapability> readonly capabilities: Set<StoreCapability>
set(namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<Either<StoreError, void>> 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 // 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) (update) => console.log("Collection updated:", update)
) )
``` ```

View file

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

View file

@ -13,28 +13,47 @@ import {
StoreEventEmitter, StoreEventEmitter,
} from '@store/v/1'; } from '@store/v/1';
const STORE_PATH = `${window.location.host}.hoppscotch.store`
type NamespacedData = Record<string, Record<string, StoredData>>; type NamespacedData = Record<string, Record<string, StoredData>>;
class TauriStoreManager { class TauriStoreManager {
private static instance: TauriStoreManager; private static instances: Map<string, TauriStoreManager> = new Map();
private store: Store | null = null; private store: Store | null = null;
private listeners = new Map<string, Set<(payload: StoreEvents['change']) => void>>(); private listeners = new Map<string, Set<(payload: StoreEvents['change']) => void>>();
private data: NamespacedData = {}; private data: NamespacedData = {};
private storePath: string;
private constructor() {} private constructor(storePath: string) {
this.storePath = storePath;
}
static new(): TauriStoreManager { static new(storePath: string): TauriStoreManager {
if (!TauriStoreManager.instance) { if (TauriStoreManager.instances.has(storePath)) {
TauriStoreManager.instance = new TauriStoreManager(); 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> { async init(): Promise<void> {
if (!this.store) { 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'); const loadedData = await this.store.get<NamespacedData>('data');
this.data = loadedData ?? {}; this.data = loadedData ?? {};
@ -123,7 +142,7 @@ class TauriStoreManager {
return Object.keys(this.data[namespace] || {}); 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}`; const watchKey = `${namespace}:${key}`;
return { return {
on: <K extends keyof StoreEvents>( on: <K extends keyof StoreEvents>(
@ -172,6 +191,7 @@ class TauriStoreManager {
this.store = null; this.store = null;
this.data = {}; this.data = {};
this.listeners.clear(); this.listeners.clear();
TauriStoreManager.instances.delete(this.storePath);
} }
} }
} }
@ -182,9 +202,9 @@ export const implementation: VersionedAPI<StoreV1> = {
id: 'tauri-store', id: 'tauri-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace', 'secure']), capabilities: new Set(['permanent', 'structured', 'watch', 'namespace', 'secure']),
async init() { async init(storePath: string) {
try { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
await manager.init(); await manager.init();
return E.right(undefined); return E.right(undefined);
} catch (error) { } 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
const existingData = await manager.getRaw(namespace, key); const existingData = await manager.getRaw(namespace, key);
const createdAt = existingData?.metadata.createdAt || new Date().toISOString() const createdAt = existingData?.metadata.createdAt || new Date().toISOString()
const updatedAt = 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return E.right(await manager.get<T>(namespace, key)); return E.right(await manager.get<T>(namespace, key));
} catch (error) { } catch (error) {
return E.left({ 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return E.right(await manager.has(namespace, key)); return E.right(await manager.has(namespace, key));
} catch (error) { } catch (error) {
return E.left({ 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return E.right(await manager.delete(namespace, key)); return E.right(await manager.delete(namespace, key));
} catch (error) { } catch (error) {
return E.left({ 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
await manager.clear(namespace); await manager.clear(namespace);
return E.right(undefined); return E.right(undefined);
} catch (error) { } 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listNamespaces()); return E.right(await manager.listNamespaces());
} catch (error) { } catch (error) {
return E.left({ 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 { try {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listKeys(namespace)); return E.right(await manager.listKeys(namespace));
} catch (error) { } catch (error) {
return E.left({ return E.left({
@ -306,8 +326,8 @@ export const implementation: VersionedAPI<StoreV1> = {
} }
}, },
watch(namespace: string, key: string): StoreEventEmitter<StoreEvents> { async watch(storePath: string, namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const manager = TauriStoreManager.new(); const manager = TauriStoreManager.new(storePath);
return manager.watch(namespace, key); return manager.watch(namespace, key);
}, },
}, },

View file

@ -91,7 +91,7 @@ class BrowserStoreManager {
.map(key => key.replace(`${namespace}:`, '')); .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); const fullKey = this.getFullKey(namespace, key);
return { return {
on: (event, handler) => { on: (event, handler) => {
@ -129,7 +129,10 @@ export const implementation: VersionedAPI<StoreV1> = {
id: 'browser-store', id: 'browser-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace']), 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 { try {
return E.right(undefined); return E.right(undefined);
} catch (e) { } 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 { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
const existingData = await manager.getRaw(namespace, key); 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 { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
return E.right(await manager.get(namespace, key)); 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 { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
return E.right(await manager.has(namespace, key)); 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 { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
return E.right(await manager.delete(namespace, key)); 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 { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
await manager.clear(namespace); await manager.clear(namespace);
@ -225,7 +228,7 @@ export const implementation: VersionedAPI<StoreV1> = {
} }
}, },
async listNamespaces() { async listNamespaces(_storePath){
try { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
return E.right(await manager.listNamespaces()); return E.right(await manager.listNamespaces());
@ -238,7 +241,7 @@ export const implementation: VersionedAPI<StoreV1> = {
} }
}, },
async listKeys(namespace) { async listKeys(_storePath, namespace) {
try { try {
const manager = BrowserStoreManager.new(); const manager = BrowserStoreManager.new();
return E.right(await manager.listKeys(namespace)); 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(); const manager = BrowserStoreManager.new();
return manager.watch(namespace, key); return manager.watch(namespace, key);
}, },

View file

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