Compare commits

...

1 Commits

Author SHA1 Message Date
abeatrix 1e172ac8d0 Sqlite3 for Global Storage 2026-01-21 18:53:02 -08:00
12 changed files with 645 additions and 31 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ import path from "path"
import type { Memento, SecretStorage } from "vscode"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineClient, ClineExtensionContext } from "@/shared/clients"
import { globalStorage } from "@/shared/storage"
import { ExtensionKind, ExtensionMode, URI } from "./vscode-shim"
const SETTINGS_SUBFOLDER = "data"
@@ -267,7 +268,7 @@ export function initializeCliContext(config: CliContextConfig = {}) {
extensionMode: EXTENSION_MODE,
// Set up KV stores
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
globalState: (globalStorage.init("cli") as any) || new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Set up URIs
+2 -1
View File
@@ -20,6 +20,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { ClineExtensionContext } from "@/shared/clients"
import { ShowMessageType } from "@/shared/proto/index.host"
import { globalStorage } from "@/shared/storage"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import {
getTaskHistoryStateFilePath,
@@ -734,7 +735,7 @@ export class StateManager {
// Route task history persistence to file, not VS Code globalState
return writeTaskHistoryToState(this.globalStateCache[key])
}
return this.context.globalState.update(key, this.globalStateCache[key])
return globalStorage.update(key, this.globalStateCache[key])
}),
)
} catch (error) {
+3 -2
View File
@@ -14,6 +14,7 @@ import {
import { Controller } from "@/core/controller"
import { ClineExtensionContext } from "@/shared/clients"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { globalStorage } from "@/shared/storage"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { readTaskHistoryFromState } from "../disk"
@@ -35,13 +36,13 @@ export async function readWorkspaceStateFromDisk(context: ClineExtensionContext)
}, {} as LocalState)
}
export async function readGlobalStateFromDisk(context: ClineExtensionContext): Promise<GlobalStateAndSettings> {
export async function readGlobalStateFromDisk(_context: ClineExtensionContext): Promise<GlobalStateAndSettings> {
try {
// Batch read all state values in a single optimized pass
const stateValues = new Map<string, any>()
// Read all values at once for better performance
for (const key of GlobalStateAndSettingKeys) {
const value = context.globalState.get(key as string)
const value = globalStorage.get(key as string)
stateValues.set(key, value)
}
+2 -1
View File
@@ -4,6 +4,7 @@ import * as cheerio from "cheerio"
import { Browser, Page } from "puppeteer-core"
import TurndownService from "turndown"
import * as vscode from "vscode"
import { globalStorage } from "@/shared/storage"
import { ensureChromiumExists } from "./utils"
export class UrlContentFetcher {
@@ -21,7 +22,7 @@ export class UrlContentFetcher {
}
const stats = await ensureChromiumExists()
// Read browser settings from globalState for custom args only
const browserSettings = this.context.globalState.get<BrowserSettings>("browserSettings", DEFAULT_BROWSER_SETTINGS)
const browserSettings = globalStorage.get<BrowserSettings>("browserSettings", DEFAULT_BROWSER_SETTINGS)
const customArgsStr = browserSettings.customArgs || ""
const customArgs = customArgsStr.trim() ? customArgsStr.split(/\s+/) : []
this.browser = await stats.puppeteer.launch({
+12 -12
View File
@@ -82,7 +82,7 @@ export interface ClineExtensionContext {
/**
* The absolute file path of the directory containing the extension. Shorthand
* notation for {@link TextDocument.uri ExtensionContext.extensionUri.fsPath} (independent of the uri scheme).
* notation for {@link TextDocument.uri ClineExtensionContext.extensionUri.fsPath} (independent of the uri scheme).
*/
readonly extensionPath: string
@@ -96,7 +96,7 @@ export interface ClineExtensionContext {
* Get the absolute path of a resource contained in the extension.
*
* *Note* that an absolute uri can be constructed via {@linkcode Uri.joinPath} and
* {@linkcode ExtensionContext.extensionUri extensionUri}, e.g. `vscode.Uri.joinPath(context.extensionUri, relativePath);`
* {@linkcode ClineExtensionContext.extensionUri extensionUri}, e.g. `vscode.Uri.joinPath(context.extensionUri, relativePath);`
*
* @param relativePath A relative path to a resource contained in the extension.
* @returns The absolute path of the resource.
@@ -109,8 +109,8 @@ export interface ClineExtensionContext {
* up to the extension. However, the parent directory is guaranteed to be existent.
* The value is `undefined` when no workspace nor folder has been opened.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode ClineExtensionContext.workspaceState workspaceState} or
* {@linkcode ClineExtensionContext.globalState globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
@@ -122,10 +122,10 @@ export interface ClineExtensionContext {
* can store private state. The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode ClineExtensionContext.workspaceState workspaceState} or
* {@linkcode ClineExtensionContext.globalState globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.storageUri storageUri} instead.
* @deprecated Use {@link ClineExtensionContext.storageUri storageUri} instead.
*/
readonly storagePath: string | undefined
@@ -134,7 +134,7 @@ export interface ClineExtensionContext {
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode ClineExtensionContext.globalState globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
@@ -146,9 +146,9 @@ export interface ClineExtensionContext {
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
* Use {@linkcode ClineExtensionContext.globalState globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.globalStorageUri globalStorageUri} instead.
* @deprecated Use {@link ClineExtensionContext.globalStorageUri globalStorageUri} instead.
*/
readonly globalStoragePath: string
@@ -167,7 +167,7 @@ export interface ClineExtensionContext {
* The directory might not exist on disk and creation is up to the extension. However,
* the parent directory is guaranteed to be existent.
*
* @deprecated Use {@link ExtensionContext.logUri logUri} instead.
* @deprecated Use {@link ClineExtensionContext.logUri logUri} instead.
*/
readonly logPath: string
@@ -208,7 +208,7 @@ interface Extension<T> {
/**
* The absolute file path of the directory containing this extension. Shorthand
* notation for {@link Extension.extensionUri Extension.extensionUri.fsPath} (independent of the uri scheme).
* notation for {@link ClineExtensionContext.extensionUri ClineExtensionContext.extensionUri.fsPath} (independent of the uri scheme).
*/
readonly extensionPath: string
+5
View File
@@ -85,6 +85,11 @@ export class ClineBlobStorage extends ClineStorage {
}
}
protected _keys(): readonly string[] {
// Blob storage doesn't support listing keys
return []
}
/**
* Check if the storage is properly initialized and ready to use.
*/
+4
View File
@@ -15,6 +15,10 @@ export class ClineFileStorage extends ClineStorage {
this.read()
}
protected _keys(): readonly string[] {
return Array.from(this.cache.keys())
}
override async _get(key: string): Promise<string | undefined> {
try {
return this.cache.get(key) || undefined
+14
View File
@@ -25,6 +25,15 @@ export class ClineSecretStorage extends ClineStorage {
return this.secretStorage
}
override async get(key: string): Promise<string | undefined> {
try {
return await this._get(key)
} catch (error) {
console.error(`[${this.name}] failed to get '${key}':`, error)
return undefined
}
}
public init(store: SecretStores) {
if (!this.secretStorage) {
this.secretStorage = store
@@ -32,6 +41,11 @@ export class ClineSecretStorage extends ClineStorage {
return this.secretStorage
}
protected _keys(): readonly string[] {
// Secret storage doesn't support listing keys for security reasons
return []
}
protected async _get(key: string): Promise<string | undefined> {
try {
return await this.storage.get(key)
+246
View File
@@ -0,0 +1,246 @@
import * as fs from "node:fs"
import os from "node:os"
import * as path from "node:path"
import Database from "better-sqlite3"
import { ClineStorage } from "./ClineStorage"
/**
* A storage implementation that uses SQLite database to store key-value pairs.
* Uses better-sqlite3 for synchronous, high-performance database operations.
*/
export class ClineSqliteStorage extends ClineStorage {
override name = "ClineSqliteStorage"
private static store: ClineSqliteStorage | null = null
static get instance(): ClineSqliteStorage {
if (!ClineSqliteStorage.store) {
ClineSqliteStorage.store = new ClineSqliteStorage()
}
return ClineSqliteStorage.store
}
private db: Database.Database | undefined
private dbPath: string | undefined
private constructor() {
super()
}
/**
* Initialize the storage with a client name.
* Must be called before using the storage.
* If already initialized with different client, closes current connection first.
*/
public init(client: string, dbPath?: string): ClineSqliteStorage {
const dir = dbPath || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const newDbPath = path.join(dir, "data", "users", client, "cline_storage_test.db")
// If already initialized with the same path, skip
if (this.db && this.dbPath === newDbPath) {
return this
}
// Close existing connection if initializing with different path
if (this.db) {
this.close()
}
this.dbPath = newDbPath
this.initializeDatabase()
return this
}
private ensureInitialized(): void {
if (!this.db || !this.dbPath) {
throw new Error("[ClineSqliteStorage] init() must be called before using the storage")
}
}
private initializeDatabase(): void {
if (!this.dbPath) {
throw new Error("[ClineSqliteStorage] dbPath not set")
}
console.log("[ClineSqliteStorage] Initializing database at:", this.dbPath)
try {
// Ensure directory exists
const dir = path.dirname(this.dbPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Open database connection
this.db = new Database(this.dbPath)
// Create table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS storage (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)
// Enable WAL mode for better concurrent access
this.db.pragma("journal_mode = WAL")
} catch (error) {
console.error("[ClineSqliteStorage] initialization failed:", error)
throw error
}
}
protected _keys(): readonly string[] {
this.ensureInitialized()
try {
const stmt = this.db!.prepare("SELECT key FROM storage")
const rows = stmt.all() as { key: string }[]
return rows.map((row) => row.key)
} catch (error) {
console.error("[ClineSqliteStorage] failed to get keys:", error)
return []
}
}
/**
* Synchronous get method - better-sqlite3 supports synchronous operations
*/
protected override _getSync(key: string): string | undefined {
this.ensureInitialized()
try {
const stmt = this.db!.prepare("SELECT value FROM storage WHERE key = ?")
const row = stmt.get(key) as { value: string } | undefined
const value = row?.value
console.log(`[ClineSqliteStorage] Get key '${key}': ${value ? value.substring(0, 50) : "undefined"}`)
return value
} catch (error) {
console.error(`[ClineSqliteStorage] failed to get '${key}':`, error)
return undefined
}
}
protected async _get(key: string): Promise<string | undefined> {
// Delegate to synchronous version since better-sqlite3 is synchronous
return this._getSync(key)
}
protected async _store(key: string, value: string): Promise<void> {
this.ensureInitialized()
try {
console.log(`[ClineSqliteStorage] Storing key '${key}' with value:`, value.substring(0, 100))
const stmt = this.db!.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
stmt.run(key, value)
console.log(`[ClineSqliteStorage] Successfully stored key '${key}'`)
} catch (error) {
console.error(`[ClineSqliteStorage] failed to store '${key}':`, error)
throw error
}
}
protected async _delete(key: string): Promise<void> {
this.ensureInitialized()
try {
const stmt = this.db!.prepare("DELETE FROM storage WHERE key = ?")
stmt.run(key)
} catch (error) {
console.error(`[ClineSqliteStorage] failed to delete '${key}':`, error)
throw error
}
}
/**
* Get all keys stored in the database.
*/
public async getAllKeys(): Promise<string[]> {
this.ensureInitialized()
try {
const stmt = this.db!.prepare("SELECT key FROM storage")
const rows = stmt.all() as { key: string }[]
return rows.map((row) => row.key)
} catch (error) {
console.error("[ClineSqliteStorage] failed to get all keys:", error)
return []
}
}
/**
* Get all key-value pairs stored in the database.
*/
public async getAll(): Promise<Record<string, string>> {
this.ensureInitialized()
try {
const stmt = this.db!.prepare("SELECT key, value FROM storage")
const rows = stmt.all() as { key: string; value: string }[]
return rows.reduce(
(acc, row) => {
acc[row.key] = row.value
return acc
},
{} as Record<string, string>,
)
} catch (error) {
console.error("[ClineSqliteStorage] failed to get all entries:", error)
return {}
}
}
/**
* Clear all entries from the database.
*/
public async clear(): Promise<void> {
this.ensureInitialized()
try {
this.db!.exec("DELETE FROM storage")
} catch (error) {
console.error("[ClineSqliteStorage] failed to clear storage:", error)
throw error
}
}
/**
* Close the database connection.
* Should be called when the storage is no longer needed.
*/
public close(): void {
if (this.db) {
try {
this.db.close()
} catch (error) {
console.error("[ClineSqliteStorage] failed to close database:", error)
} finally {
this.db = undefined
this.dbPath = undefined
}
}
}
/**
* Get database statistics.
*/
public getStats(): { totalKeys: number; dbSizeBytes: number } {
this.ensureInitialized()
try {
const countStmt = this.db!.prepare("SELECT COUNT(*) as count FROM storage")
const countRow = countStmt.get() as { count: number }
const dbSizeBytes = fs.existsSync(this.dbPath!) ? fs.statSync(this.dbPath!).size : 0
return {
totalKeys: countRow.count,
dbSizeBytes,
}
} catch (error) {
console.error("[ClineSqliteStorage] failed to get stats:", error)
return { totalKeys: 0, dbSizeBytes: 0 }
}
}
}
/**
* Singleton instance of ClineSqliteStorage
*/
export const sqliteStorage = ClineSqliteStorage.instance
+94 -14
View File
@@ -6,8 +6,8 @@ export type StorageEventListener = (event: ClineStorageChangeEvent) => Promise<v
/**
* An abstract storage class that provides a template for storage operations.
* Implements a Memento-like interface compatible with VS Code's storage API.
* Subclasses must implement the protected abstract methods to define their storage logic.
* The public methods (get, store, delete) are final and cannot be overridden.
*/
export abstract class ClineStorage {
/**
@@ -18,7 +18,6 @@ export abstract class ClineStorage {
* List of subscribers to storage change events.
*/
private readonly subscribers: Array<StorageEventListener> = []
/**
* Subscribe to storage change events.
*/
@@ -40,10 +39,75 @@ export abstract class ClineStorage {
}
/**
* Get a value from storage. This method is final and cannot be overridden.
* Subclasses should implement _get() to define their storage retrieval logic.
* Returns the stored keys.
* Subclasses must implement _keys() to provide their key list.
*/
public async get(key: string): Promise<string | undefined> {
public keys(): readonly string[] {
try {
return this._keys()
} catch (error) {
console.error(`[${this.name}] failed to get keys:`, error)
return []
}
}
/**
* Return a value.
*
* @param key A string.
* @param defaultValue A value that should be returned when there is no value with the given key.
* @returns The stored value, the defaultValue, or undefined.
*/
public get<T>(key: string): T | undefined
public get<T>(key: string, defaultValue: T): T
public get<T>(key: string, defaultValue?: T): T | undefined {
try {
const rawValue = this._getSync(key)
if (rawValue === undefined) {
return defaultValue
}
// Parse JSON if it looks like a JSON string
try {
return JSON.parse(rawValue) as T
} catch {
// If parsing fails, return as-is (for plain strings)
return rawValue as T
}
} catch (error) {
console.error(`[${this.name}] failed to get '${key}':`, error)
return defaultValue
}
}
/**
* Store a value. The value must be JSON-stringifyable.
*
* Note that using `undefined` as value removes the key from the underlying storage.
*
* @param key A string.
* @param value A value. MUST not contain cyclic references.
*/
public async update(key: string, value: any): Promise<void> {
try {
if (value === undefined) {
await this._delete(key)
} else {
// Stringify non-string values
const stringValue = typeof value === "string" ? value : JSON.stringify(value)
await this._store(key, stringValue)
}
await this.fire(key)
} catch (error) {
console.error(`[${this.name}] failed to update '${key}':`, error)
}
}
// Legacy methods for backward compatibility
/**
* @deprecated Use get() instead for Memento compatibility
*/
public async getString(key: string): Promise<string | undefined> {
try {
return await this._get(key)
} catch (error) {
@@ -53,13 +117,15 @@ export abstract class ClineStorage {
}
/**
* Store a value in storage. This method is final and cannot be overridden.
* Subclasses should implement _store() to define their storage logic.
* This method automatically fires change events after storing.
* @deprecated Use update() instead for Memento compatibility
*/
public async store(key: string, value: string): Promise<void> {
public async store(key: string, value?: string): Promise<void> {
try {
await this._store(key, value)
if (value) {
await this._store(key, value)
} else {
await this._delete(key)
}
await this.fire(key)
} catch (error) {
console.error(`[${this.name}] failed to store '${key}':`, error)
@@ -67,9 +133,7 @@ export abstract class ClineStorage {
}
/**
* Delete a value from storage. This method is final and cannot be overridden.
* Subclasses should implement _delete() to define their deletion logic.
* This method automatically fires change events after deletion.
* @deprecated Use update(key, undefined) instead for Memento compatibility
*/
public async delete(key: string): Promise<void> {
try {
@@ -81,7 +145,19 @@ export abstract class ClineStorage {
}
/**
* Abstract method that subclasses must implement to retrieve values from their storage.
* Abstract method that subclasses must implement to return all stored keys.
*/
protected abstract _keys(): readonly string[]
/**
* Synchronous method to get a value.
*/
protected _getSync(key: string): string | undefined {
return this.get(key)
}
/**
* Abstract method that subclasses must implement to asynchronously retrieve values.
*/
protected abstract _get(key: string): Promise<string | undefined>
@@ -105,6 +181,10 @@ export class InMemoryClineStorage extends ClineStorage {
*/
private readonly _cache = new Map<string, string>()
protected _keys(): readonly string[] {
return Array.from(this._cache.keys())
}
protected async _get(key: string): Promise<string | undefined> {
return this._cache.get(key)
}
@@ -0,0 +1,258 @@
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import { ClineSqliteStorage, sqliteStorage } from "../ClineSqliteStorage"
describe("ClineSqliteStorage", () => {
let storage: ClineSqliteStorage
let tempDir: string
beforeEach(() => {
// Create a temporary directory for the test database
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-sqlite-test-"))
// Use singleton instance and initialize with test client
storage = sqliteStorage
storage.init("test-client", tempDir)
})
afterEach(() => {
// Clean up: close database and remove temp directory
storage.close()
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
describe("basic operations", () => {
it("should store and retrieve a value", async () => {
await storage.store("testKey", "testValue")
const value = await storage.get("testKey")
expect(value).to.equal("testValue")
})
it("should return undefined for non-existent key", async () => {
const value = await storage.get("nonExistentKey")
expect(value).to.be.undefined
})
it("should overwrite existing value", async () => {
await storage.store("testKey", "value1")
await storage.store("testKey", "value2")
const value = await storage.get("testKey")
expect(value).to.equal("value2")
})
it("should delete a value", async () => {
await storage.store("testKey", "testValue")
await storage.delete("testKey")
const value = await storage.get("testKey")
expect(value).to.be.undefined
})
it("should handle deletion of non-existent key", async () => {
await storage.delete("nonExistentKey")
// Should not throw
})
it("should store and retrieve multiple key-value pairs", async () => {
await storage.store("key1", "value1")
await storage.store("key2", "value2")
await storage.store("key3", "value3")
const value1 = await storage.get("key1")
const value2 = await storage.get("key2")
const value3 = await storage.get("key3")
expect(value1).to.equal("value1")
expect(value2).to.equal("value2")
expect(value3).to.equal("value3")
})
})
describe("special characters and edge cases", () => {
it("should handle keys with special characters", async () => {
const specialKey = "key:with/special@chars#test"
await storage.store(specialKey, "value")
const value = await storage.get(specialKey)
expect(value).to.equal("value")
})
it("should handle values with special characters", async () => {
const specialValue = '{"json": "value", "with": ["arrays", "and", "objects"]}'
await storage.store("jsonKey", specialValue)
const value = await storage.get("jsonKey")
expect(value).to.equal(specialValue)
})
it("should handle empty string values", async () => {
await storage.store("emptyKey", "")
const value = await storage.get("emptyKey")
expect(value).to.equal("")
})
it("should handle large values", async () => {
const largeValue = "x".repeat(100000) // 100KB string
await storage.store("largeKey", largeValue)
const value = await storage.get("largeKey")
expect(value).to.equal(largeValue)
})
it("should handle unicode characters", async () => {
const unicodeValue = "Hello 👋 世界 🌍"
await storage.store("unicodeKey", unicodeValue)
const value = await storage.get("unicodeKey")
expect(value).to.equal(unicodeValue)
})
})
describe("batch operations", () => {
it("should get all keys", async () => {
await storage.store("key1", "value1")
await storage.store("key2", "value2")
await storage.store("key3", "value3")
const keys = await storage.getAllKeys()
expect(keys).to.have.lengthOf(3)
expect(keys).to.include.members(["key1", "key2", "key3"])
})
it("should get all entries", async () => {
await storage.store("key1", "value1")
await storage.store("key2", "value2")
await storage.store("key3", "value3")
const all = await storage.getAll()
expect(all).to.deep.equal({
key1: "value1",
key2: "value2",
key3: "value3",
})
})
it("should clear all entries", async () => {
await storage.store("key1", "value1")
await storage.store("key2", "value2")
await storage.store("key3", "value3")
await storage.clear()
const keys = await storage.getAllKeys()
expect(keys).to.have.lengthOf(0)
})
})
describe("statistics", () => {
it("should return correct statistics", async () => {
await storage.store("key1", "value1")
await storage.store("key2", "value2")
const stats = storage.getStats()
expect(stats.totalKeys).to.equal(2)
expect(stats.dbSizeBytes).to.be.greaterThan(0)
})
it("should return zero stats for empty database", async () => {
const stats = storage.getStats()
expect(stats.totalKeys).to.equal(0)
expect(stats.dbSizeBytes).to.be.greaterThan(0) // WAL files may exist
})
})
describe("persistence", () => {
it("should persist data across init calls", async () => {
await storage.store("persistKey", "persistValue")
const firstValue = await storage.get("persistKey")
expect(firstValue).to.equal("persistValue")
// Verify persistence by checking the data is still there
const value = await storage.get("persistKey")
expect(value).to.equal("persistValue")
})
})
describe("change events", () => {
it("should fire change event on store", async () => {
let eventFired = false
let eventKey = ""
storage.onDidChange((event) => {
eventFired = true
eventKey = event.key
return Promise.resolve()
})
await storage.store("testKey", "testValue")
expect(eventFired).to.be.true
expect(eventKey).to.equal("testKey")
})
it("should fire change event on delete", async () => {
let eventFired = false
let eventKey = ""
await storage.store("testKey", "testValue")
storage.onDidChange((event) => {
eventFired = true
eventKey = event.key
return Promise.resolve()
})
await storage.delete("testKey")
expect(eventFired).to.be.true
expect(eventKey).to.equal("testKey")
})
it("should support multiple subscribers", async () => {
let event1Fired = false
let event2Fired = false
storage.onDidChange(() => {
event1Fired = true
return Promise.resolve()
})
storage.onDidChange(() => {
event2Fired = true
return Promise.resolve()
})
await storage.store("testKey", "testValue")
expect(event1Fired).to.be.true
expect(event2Fired).to.be.true
})
it("should unsubscribe from events", async () => {
let eventCount = 0
const unsubscribe = storage.onDidChange(() => {
eventCount++
return Promise.resolve()
})
await storage.store("key1", "value1")
expect(eventCount).to.equal(1)
unsubscribe()
await storage.store("key2", "value2")
expect(eventCount).to.equal(1) // Should not increment
})
})
describe("error handling", () => {
it("should handle operations after close", () => {
storage.close()
// Operations should throw after close
expect(() => storage.getStats()).to.not.throw()
expect(storage.getStats().totalKeys).to.equal(0)
})
})
})
+3
View File
@@ -0,0 +1,3 @@
import { ClineSqliteStorage } from "./ClineSqliteStorage"
export const globalStorage = ClineSqliteStorage.instance