mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e172ac8d0 |
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ClineSqliteStorage } from "./ClineSqliteStorage"
|
||||
|
||||
export const globalStorage = ClineSqliteStorage.instance
|
||||
Reference in New Issue
Block a user