ubersearch 0.0.0-development
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/package.json +76 -0
- package/src/app/index.ts +30 -0
- package/src/bootstrap/container.ts +157 -0
- package/src/cli.ts +380 -0
- package/src/config/defineConfig.ts +176 -0
- package/src/config/load.ts +368 -0
- package/src/config/types.ts +86 -0
- package/src/config/validation.ts +148 -0
- package/src/core/cache.ts +74 -0
- package/src/core/container.ts +268 -0
- package/src/core/credits/CreditManager.ts +158 -0
- package/src/core/credits/CreditStateProvider.ts +151 -0
- package/src/core/credits/FileCreditStateProvider.ts +137 -0
- package/src/core/credits/index.ts +3 -0
- package/src/core/docker/dockerComposeHelper.ts +177 -0
- package/src/core/docker/dockerLifecycleManager.ts +361 -0
- package/src/core/docker/index.ts +8 -0
- package/src/core/logger.ts +146 -0
- package/src/core/orchestrator.ts +103 -0
- package/src/core/paths.ts +157 -0
- package/src/core/provider/ILifecycleProvider.ts +120 -0
- package/src/core/provider/ProviderFactory.ts +120 -0
- package/src/core/provider.ts +61 -0
- package/src/core/serviceKeys.ts +45 -0
- package/src/core/strategy/AllProvidersStrategy.ts +245 -0
- package/src/core/strategy/FirstSuccessStrategy.ts +98 -0
- package/src/core/strategy/ISearchStrategy.ts +94 -0
- package/src/core/strategy/StrategyFactory.ts +204 -0
- package/src/core/strategy/index.ts +9 -0
- package/src/core/strategy/types.ts +56 -0
- package/src/core/types.ts +58 -0
- package/src/index.ts +1 -0
- package/src/plugin/PluginRegistry.ts +336 -0
- package/src/plugin/builtin.ts +130 -0
- package/src/plugin/index.ts +33 -0
- package/src/plugin/types.ts +212 -0
- package/src/providers/BaseProvider.ts +49 -0
- package/src/providers/brave.ts +66 -0
- package/src/providers/constants.ts +13 -0
- package/src/providers/helpers/index.ts +24 -0
- package/src/providers/helpers/lifecycleHelpers.ts +110 -0
- package/src/providers/helpers/resultMappers.ts +168 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/linkup.ts +114 -0
- package/src/providers/retry.ts +95 -0
- package/src/providers/searchxng.ts +163 -0
- package/src/providers/tavily.ts +73 -0
- package/src/providers/types/index.ts +185 -0
- package/src/providers/utils.ts +182 -0
- package/src/tool/allSearchTool.ts +110 -0
- package/src/tool/interface.ts +71 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Injection Container
|
|
3
|
+
*
|
|
4
|
+
* Central service locator that manages service registration and resolution.
|
|
5
|
+
* Implements factory pattern with singleton and transient lifetimes.
|
|
6
|
+
*
|
|
7
|
+
* @module core/container
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Service identifier - can be string or Symbol for unique identification
|
|
12
|
+
*/
|
|
13
|
+
export type ServiceIdentifier<_T = unknown> = string | symbol;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Container binding configuration
|
|
17
|
+
*/
|
|
18
|
+
export interface ContainerBinding<T> {
|
|
19
|
+
/** Factory function that creates service instance */
|
|
20
|
+
factory: (container: Container) => T;
|
|
21
|
+
/** Whether service should be singleton (cached) */
|
|
22
|
+
singleton: boolean;
|
|
23
|
+
/** Cached instance for singleton services */
|
|
24
|
+
cached?: T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Dependency Injection Container
|
|
29
|
+
*
|
|
30
|
+
* Manages service registration and resolution with support for:
|
|
31
|
+
* - Singleton and transient lifetimes
|
|
32
|
+
* - Circular dependency detection
|
|
33
|
+
* - Type-safe resolution
|
|
34
|
+
* - Factory pattern for lazy instantiation
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const container = new Container();
|
|
39
|
+
*
|
|
40
|
+
* // Register singleton service
|
|
41
|
+
* container.singleton('config', () => loadConfig());
|
|
42
|
+
*
|
|
43
|
+
* // Register transient service
|
|
44
|
+
* container.bind('strategy', (c) => new SearchStrategy(c.get('config')));
|
|
45
|
+
*
|
|
46
|
+
* // Resolve service
|
|
47
|
+
* const config = container.get<Config>('config');
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export class Container {
|
|
51
|
+
/** Internal storage for service bindings */
|
|
52
|
+
private bindings = new Map<ServiceIdentifier, ContainerBinding<unknown>>();
|
|
53
|
+
|
|
54
|
+
/** Stack to detect circular dependencies during resolution */
|
|
55
|
+
private resolutionStack = new Set<ServiceIdentifier>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a transient service (new instance each time)
|
|
59
|
+
*
|
|
60
|
+
* @param id - Service identifier
|
|
61
|
+
* @param factory - Factory function that creates the service
|
|
62
|
+
* @template T - Service type
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* container.bind('searchStrategy', (c) => new AllProvidersStrategy());
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
bind<T>(id: ServiceIdentifier<T>, factory: (container: Container) => T): void {
|
|
70
|
+
this.bindings.set(id, {
|
|
71
|
+
factory,
|
|
72
|
+
singleton: false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a singleton service (cached instance)
|
|
78
|
+
*
|
|
79
|
+
* @param id - Service identifier
|
|
80
|
+
* @param factory - Factory function that creates the service
|
|
81
|
+
* @template T - Service type
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* container.singleton('config', () => loadConfiguration());
|
|
86
|
+
* container.singleton('creditManager', (c) => new CreditManager(
|
|
87
|
+
* c.get('engines'),
|
|
88
|
+
* c.get('creditProvider')
|
|
89
|
+
* ));
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
singleton<T>(id: ServiceIdentifier<T>, factory: (container: Container) => T): void {
|
|
93
|
+
this.bindings.set(id, {
|
|
94
|
+
factory,
|
|
95
|
+
singleton: true,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a service instance
|
|
101
|
+
*
|
|
102
|
+
* @param id - Service identifier
|
|
103
|
+
* @returns Service instance
|
|
104
|
+
* @template T - Service type
|
|
105
|
+
* @throws {Error} If service is not registered or circular dependency detected
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const config = container.get<Config>('config');
|
|
110
|
+
* const manager = container.get<CreditManager>('creditManager');
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
get<T>(id: ServiceIdentifier<T>): T {
|
|
114
|
+
// Check for circular dependency
|
|
115
|
+
if (this.resolutionStack.has(id)) {
|
|
116
|
+
const chain = [...this.resolutionStack, id].map(String).join(" -> ");
|
|
117
|
+
throw new Error(`Circular dependency detected: ${chain}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const binding = this.bindings.get(id);
|
|
121
|
+
if (!binding) {
|
|
122
|
+
const registered = Array.from(this.bindings.keys()).map(String);
|
|
123
|
+
throw new Error(
|
|
124
|
+
`No binding found for '${String(id)}'. Registered services: [${registered.join(", ")}]`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle singleton caching
|
|
129
|
+
if (binding.singleton && binding.cached !== undefined) {
|
|
130
|
+
return binding.cached;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Track resolution for circular dependency detection
|
|
134
|
+
this.resolutionStack.add(id);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// Create instance using factory
|
|
138
|
+
const instance = binding.factory(this);
|
|
139
|
+
|
|
140
|
+
// Cache singleton instances
|
|
141
|
+
if (binding.singleton) {
|
|
142
|
+
binding.cached = instance;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return instance;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Enhance error message with context
|
|
148
|
+
if (error instanceof Error) {
|
|
149
|
+
throw new Error(`Failed to resolve service '${String(id)}': ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
} finally {
|
|
153
|
+
// Clean up resolution stack
|
|
154
|
+
this.resolutionStack.delete(id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if a service is registered
|
|
160
|
+
*
|
|
161
|
+
* @param id - Service identifier
|
|
162
|
+
* @returns true if service is registered
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* if (container.has('optionalService')) {
|
|
167
|
+
* const service = container.get('optionalService');
|
|
168
|
+
* }
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
has(id: ServiceIdentifier): boolean {
|
|
172
|
+
return this.bindings.has(id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remove a service binding
|
|
177
|
+
*
|
|
178
|
+
* @param id - Service identifier to remove
|
|
179
|
+
* @returns true if binding was removed, false if it didn't exist
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* container.unbind('oldService');
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
unbind(id: ServiceIdentifier): boolean {
|
|
187
|
+
return this.bindings.delete(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear all service bindings
|
|
192
|
+
* Useful for testing or resetting container state
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```typescript
|
|
196
|
+
* container.reset();
|
|
197
|
+
* // Container is now empty
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
reset(): void {
|
|
201
|
+
this.bindings.clear();
|
|
202
|
+
this.resolutionStack.clear();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get list of all registered service identifiers
|
|
207
|
+
*
|
|
208
|
+
* @returns Array of service identifiers
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* const services = container.getRegisteredServices();
|
|
213
|
+
* console.log('Available services:', services);
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
getRegisteredServices(): ServiceIdentifier[] {
|
|
217
|
+
return Array.from(this.bindings.keys());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get service information including lifetime and factory details
|
|
222
|
+
* Useful for debugging and introspection
|
|
223
|
+
*
|
|
224
|
+
* @param id - Service identifier
|
|
225
|
+
* @returns Service information or undefined if not found
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```typescript
|
|
229
|
+
* const info = container.getServiceInfo('config');
|
|
230
|
+
* console.log('Service lifetime:', info?.singleton ? 'singleton' : 'transient');
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
getServiceInfo(id: ServiceIdentifier):
|
|
234
|
+
| {
|
|
235
|
+
singleton: boolean;
|
|
236
|
+
cached: boolean;
|
|
237
|
+
factory: (container: Container) => unknown;
|
|
238
|
+
}
|
|
239
|
+
| undefined {
|
|
240
|
+
const binding = this.bindings.get(id);
|
|
241
|
+
if (!binding) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
singleton: binding.singleton,
|
|
247
|
+
cached: binding.cached !== undefined,
|
|
248
|
+
factory: binding.factory,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Global container instance for convenience
|
|
255
|
+
* Use this for application-wide dependency injection
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```typescript
|
|
259
|
+
* import { container } from './core/container';
|
|
260
|
+
*
|
|
261
|
+
* // Register services globally
|
|
262
|
+
* container.singleton('config', () => loadConfig());
|
|
263
|
+
*
|
|
264
|
+
* // Use in any module
|
|
265
|
+
* const config = container.get<Config>('config');
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export const container = new Container();
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit management system for tracking usage across providers
|
|
3
|
+
* Refactored to use dependency injection - no file I/O dependencies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EngineConfig } from "../../config/types";
|
|
7
|
+
import type { EngineId } from "../types";
|
|
8
|
+
import type { CreditState, CreditStateProvider } from "./CreditStateProvider";
|
|
9
|
+
|
|
10
|
+
export interface CreditSnapshot {
|
|
11
|
+
engineId: EngineId;
|
|
12
|
+
quota: number;
|
|
13
|
+
used: number;
|
|
14
|
+
remaining: number;
|
|
15
|
+
isExhausted: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CreditManager {
|
|
19
|
+
private engines: Map<EngineId, EngineConfig>;
|
|
20
|
+
private state: CreditState = {};
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
engines: EngineConfig[],
|
|
24
|
+
private stateProvider: CreditStateProvider,
|
|
25
|
+
) {
|
|
26
|
+
this.engines = new Map(engines.map((e) => [e.id, e]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the credit manager by loading state from provider
|
|
31
|
+
* Must be called after construction
|
|
32
|
+
*/
|
|
33
|
+
async initialize(): Promise<void> {
|
|
34
|
+
this.state = await this.stateProvider.loadState();
|
|
35
|
+
await this.resetIfNeeded();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if we need to reset monthly usage (first day of month)
|
|
40
|
+
*/
|
|
41
|
+
private async resetIfNeeded(): Promise<void> {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const currentMonth = now.toISOString().slice(0, 7); // YYYY-MM
|
|
44
|
+
|
|
45
|
+
for (const [engineId, _config] of this.engines) {
|
|
46
|
+
const record = this.state[engineId];
|
|
47
|
+
|
|
48
|
+
if (!record) {
|
|
49
|
+
this.state[engineId] = {
|
|
50
|
+
used: 0,
|
|
51
|
+
lastReset: now.toISOString(),
|
|
52
|
+
};
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lastResetMonth = record.lastReset.slice(0, 7);
|
|
57
|
+
if (lastResetMonth !== currentMonth) {
|
|
58
|
+
// New month - reset counter
|
|
59
|
+
record.used = 0;
|
|
60
|
+
record.lastReset = now.toISOString();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await this.stateProvider.saveState(this.state);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deduct credits for a search (synchronous version for immediate checks)
|
|
69
|
+
* @returns true if successful, false if exhausted
|
|
70
|
+
* @note This does NOT persist the state - call saveState() separately
|
|
71
|
+
*/
|
|
72
|
+
charge(engineId: EngineId): boolean {
|
|
73
|
+
const config = this.engines.get(engineId);
|
|
74
|
+
if (!config) {
|
|
75
|
+
throw new Error(`Unknown engine: ${engineId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const record = this.state[engineId];
|
|
79
|
+
if (!record) {
|
|
80
|
+
throw new Error(`No credit record for engine: ${engineId}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (record.used + config.creditCostPerSearch > config.monthlyQuota) {
|
|
84
|
+
return false; // Exhausted
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
record.used += config.creditCostPerSearch;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Deduct credits for a search and persist state
|
|
93
|
+
* @returns true if successful, false if exhausted
|
|
94
|
+
*/
|
|
95
|
+
async chargeAndSave(engineId: EngineId): Promise<boolean> {
|
|
96
|
+
const result = this.charge(engineId);
|
|
97
|
+
if (result) {
|
|
98
|
+
await this.stateProvider.saveState(this.state);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if engine has sufficient credits
|
|
105
|
+
*/
|
|
106
|
+
hasSufficientCredits(engineId: EngineId): boolean {
|
|
107
|
+
const config = this.engines.get(engineId);
|
|
108
|
+
if (!config) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const record = this.state[engineId];
|
|
113
|
+
if (!record) {
|
|
114
|
+
return true; // No usage yet
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return record.used + config.creditCostPerSearch <= config.monthlyQuota;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get credit snapshot for an engine
|
|
122
|
+
*/
|
|
123
|
+
getSnapshot(engineId: EngineId): CreditSnapshot {
|
|
124
|
+
const config = this.engines.get(engineId);
|
|
125
|
+
if (!config) {
|
|
126
|
+
throw new Error(`Unknown engine: ${engineId}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const record = this.state[engineId] ?? {
|
|
130
|
+
used: 0,
|
|
131
|
+
lastReset: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const remaining = Math.max(0, config.monthlyQuota - record.used);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
engineId,
|
|
138
|
+
quota: config.monthlyQuota,
|
|
139
|
+
used: record.used,
|
|
140
|
+
remaining,
|
|
141
|
+
isExhausted: remaining < config.creditCostPerSearch,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Save current state to persistence layer
|
|
147
|
+
*/
|
|
148
|
+
async saveState(): Promise<void> {
|
|
149
|
+
await this.stateProvider.saveState(this.state);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get snapshots for all engines
|
|
154
|
+
*/
|
|
155
|
+
listSnapshots(): CreditSnapshot[] {
|
|
156
|
+
return Array.from(this.engines.keys()).map((id) => this.getSnapshot(id));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit state persistence abstraction layer
|
|
3
|
+
*
|
|
4
|
+
* This module defines interfaces for credit state persistence operations,
|
|
5
|
+
* separating persistence concerns from business logic. Enables unit testing
|
|
6
|
+
* without file I/O and supports multiple persistence implementations.
|
|
7
|
+
*
|
|
8
|
+
* @module credits/CreditStateProvider
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EngineId } from "../types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents the persistent state of credit usage for search engines.
|
|
15
|
+
* Maps engine IDs to their usage tracking data.
|
|
16
|
+
*
|
|
17
|
+
* @interface CreditState
|
|
18
|
+
*/
|
|
19
|
+
export interface CreditState {
|
|
20
|
+
/**
|
|
21
|
+
* Maps engine identifiers to their credit usage information.
|
|
22
|
+
* Each entry tracks the total credits used and the last reset timestamp.
|
|
23
|
+
*/
|
|
24
|
+
[engineId: EngineId]: {
|
|
25
|
+
/**
|
|
26
|
+
* Total number of credits used by this engine in the current billing period.
|
|
27
|
+
*/
|
|
28
|
+
used: number;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ISO 8601 date string indicating when the credits were last reset.
|
|
32
|
+
* Used to determine when monthly quotas should be refreshed.
|
|
33
|
+
*/
|
|
34
|
+
lastReset: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Abstraction for credit state persistence operations.
|
|
40
|
+
*
|
|
41
|
+
* This interface defines the contract for loading, saving, and checking
|
|
42
|
+
* the existence of credit state data. Implementations can use various
|
|
43
|
+
* persistence mechanisms (file system, memory, database, etc.) while
|
|
44
|
+
* maintaining the same API for the business logic layer.
|
|
45
|
+
*
|
|
46
|
+
* All methods are asynchronous to support both synchronous and
|
|
47
|
+
* asynchronous persistence backends without blocking the main thread.
|
|
48
|
+
*
|
|
49
|
+
* @interface CreditStateProvider
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // File-based implementation
|
|
54
|
+
* class FileCreditStateProvider implements CreditStateProvider {
|
|
55
|
+
* async loadState(): Promise<CreditState> {
|
|
56
|
+
* const data = await fs.readFile(this.filePath, 'utf8');
|
|
57
|
+
* return JSON.parse(data);
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* async saveState(state: CreditState): Promise<void> {
|
|
61
|
+
* await fs.writeFile(this.filePath, JSON.stringify(state));
|
|
62
|
+
* }
|
|
63
|
+
*
|
|
64
|
+
* async stateExists(): Promise<boolean> {
|
|
65
|
+
* return fs.exists(this.filePath);
|
|
66
|
+
* }
|
|
67
|
+
* }
|
|
68
|
+
*
|
|
69
|
+
* // Memory-based implementation for testing
|
|
70
|
+
* class MemoryCreditStateProvider implements CreditStateProvider {
|
|
71
|
+
* private state: CreditState = {};
|
|
72
|
+
*
|
|
73
|
+
* async loadState(): Promise<CreditState> {
|
|
74
|
+
* return { ...this.state };
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* async saveState(state: CreditState): Promise<void> {
|
|
78
|
+
* this.state = { ...state };
|
|
79
|
+
* }
|
|
80
|
+
*
|
|
81
|
+
* async stateExists(): Promise<boolean> {
|
|
82
|
+
* return Object.keys(this.state).length > 0;
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export interface CreditStateProvider {
|
|
88
|
+
/**
|
|
89
|
+
* Load the current credit state from persistence.
|
|
90
|
+
*
|
|
91
|
+
* @returns Promise resolving to the current credit state.
|
|
92
|
+
* Returns empty object `{}` if no state exists.
|
|
93
|
+
*
|
|
94
|
+
* @throws May throw errors related to persistence layer failures
|
|
95
|
+
* (file system errors, network issues, etc.).
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const provider = new FileCreditStateProvider();
|
|
100
|
+
* const state = await provider.loadState();
|
|
101
|
+
* console.log(`Loaded state for ${Object.keys(state).length} engines`);
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
loadState(): Promise<CreditState>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Save the credit state to persistence.
|
|
108
|
+
*
|
|
109
|
+
* @param state - The credit state to persist.
|
|
110
|
+
* Should be a complete snapshot of current usage.
|
|
111
|
+
*
|
|
112
|
+
* @returns Promise that resolves when the state has been successfully saved.
|
|
113
|
+
*
|
|
114
|
+
* @throws May throw errors related to persistence layer failures
|
|
115
|
+
* (file system errors, network issues, permission errors, etc.).
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const newState: CreditState = {
|
|
120
|
+
* 'google': { used: 50, lastReset: '2024-01-15T10:30:00.000Z' },
|
|
121
|
+
* 'bing': { used: 25, lastReset: '2024-01-15T10:30:00.000Z' }
|
|
122
|
+
* };
|
|
123
|
+
* await provider.saveState(newState);
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
saveState(state: CreditState): Promise<void>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if credit state exists in persistence.
|
|
130
|
+
*
|
|
131
|
+
* Used to determine whether to load existing state or initialize
|
|
132
|
+
* with default values.
|
|
133
|
+
*
|
|
134
|
+
* @returns Promise resolving to true if state exists in persistence,
|
|
135
|
+
* false otherwise.
|
|
136
|
+
*
|
|
137
|
+
* @throws May throw errors related to persistence layer failures.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* if (await provider.stateExists()) {
|
|
142
|
+
* const state = await provider.loadState();
|
|
143
|
+
* // Process existing state
|
|
144
|
+
* } else {
|
|
145
|
+
* // Initialize with default state
|
|
146
|
+
* await provider.saveState({});
|
|
147
|
+
* }
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
stateExists(): Promise<boolean>;
|
|
151
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based implementation of CreditStateProvider
|
|
3
|
+
* Handles file I/O operations for credit state persistence using Bun's async APIs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { createLogger } from "../logger";
|
|
9
|
+
import type { CreditState, CreditStateProvider } from "./CreditStateProvider";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("CreditState");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* File-based persistence implementation of CreditStateProvider.
|
|
15
|
+
* Manages credit state storage using the local filesystem with async I/O.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* This provider stores credit state as JSON in the following locations:
|
|
19
|
+
* - Default: ~/.local/state/allsearch/credits.json
|
|
20
|
+
* - With XDG_STATE_HOME: $XDG_STATE_HOME/allsearch/credits.json
|
|
21
|
+
*
|
|
22
|
+
* Uses Bun's async file APIs for non-blocking I/O operations.
|
|
23
|
+
* The provider handles directory creation automatically and never throws errors,
|
|
24
|
+
* instead logging warnings and returning sensible defaults.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const provider = new FileCreditStateProvider();
|
|
29
|
+
* const state = await provider.loadState();
|
|
30
|
+
* await provider.saveState(newState);
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class FileCreditStateProvider implements CreditStateProvider {
|
|
34
|
+
private readonly statePath: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a new FileCreditStateProvider instance.
|
|
38
|
+
*
|
|
39
|
+
* @param statePath - Optional custom path for the state file.
|
|
40
|
+
* If not provided, uses the default location based on
|
|
41
|
+
* XDG_STATE_HOME or ~/.local/state/allsearch/credits.json
|
|
42
|
+
*/
|
|
43
|
+
constructor(statePath?: string) {
|
|
44
|
+
this.statePath = statePath ?? this.getDefaultStatePath();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Gets the default state file path following XDG Base Directory Specification.
|
|
49
|
+
*
|
|
50
|
+
* @returns The resolved state file path
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
private getDefaultStatePath(): string {
|
|
54
|
+
const base = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
55
|
+
return join(base, "allsearch", "credits.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load credit state from the file system using async I/O.
|
|
60
|
+
*
|
|
61
|
+
* @returns The loaded credit state, or an empty object if the file doesn't exist
|
|
62
|
+
* or cannot be parsed
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* This method never throws. If the file doesn't exist, an empty state is returned.
|
|
66
|
+
* If the file exists but cannot be parsed, a warning is logged and an empty state
|
|
67
|
+
* is returned. Uses Bun.file() for non-blocking file reads.
|
|
68
|
+
*/
|
|
69
|
+
async loadState(): Promise<CreditState> {
|
|
70
|
+
const file = Bun.file(this.statePath);
|
|
71
|
+
|
|
72
|
+
// Check if file exists
|
|
73
|
+
if (!(await file.exists())) {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const raw = await file.text();
|
|
79
|
+
return JSON.parse(raw) as CreditState;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
log.warn(`Failed to load credit state from ${this.statePath}:`, error);
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Save credit state to the file system using async I/O.
|
|
88
|
+
*
|
|
89
|
+
* @param state - The credit state to save
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* This method never throws. If the directory doesn't exist, it will be created
|
|
93
|
+
* recursively. If saving fails, a warning is logged but no error is thrown.
|
|
94
|
+
* Uses Bun.write() for non-blocking file writes.
|
|
95
|
+
*/
|
|
96
|
+
async saveState(state: CreditState): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
// Ensure parent directory exists
|
|
99
|
+
const { dirname } = await import("node:path");
|
|
100
|
+
const { mkdir } = await import("node:fs/promises");
|
|
101
|
+
const dir = dirname(this.statePath);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
} catch (mkdirError) {
|
|
106
|
+
// Ignore EEXIST errors
|
|
107
|
+
if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
108
|
+
throw mkdirError;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Write file using Bun's async API
|
|
113
|
+
await Bun.write(this.statePath, JSON.stringify(state, null, 2));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log.warn(`Failed to save credit state to ${this.statePath}:`, error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if the state file exists in the file system.
|
|
121
|
+
*
|
|
122
|
+
* @returns true if the state file exists, false otherwise
|
|
123
|
+
*/
|
|
124
|
+
async stateExists(): Promise<boolean> {
|
|
125
|
+
const file = Bun.file(this.statePath);
|
|
126
|
+
return await file.exists();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get the path where state is stored.
|
|
131
|
+
*
|
|
132
|
+
* @returns The absolute path to the state file
|
|
133
|
+
*/
|
|
134
|
+
getStatePath(): string {
|
|
135
|
+
return this.statePath;
|
|
136
|
+
}
|
|
137
|
+
}
|