bff-store 0.1.0
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/.claude/settings.local.json +45 -0
- package/CONTEXT.md +53 -0
- package/README.md +223 -0
- package/dist/cli.js +32577 -0
- package/dist/index.d.mts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.mjs +430 -0
- package/dist/package.json +62 -0
- package/dist/server/entry.d.mts +94 -0
- package/dist/server/entry.d.ts +94 -0
- package/dist/server/entry.js +573 -0
- package/dist/server/entry.mjs +533 -0
- package/dist/server-V7WCW4ZB.mjs +530 -0
- package/dist/storage/jsonl-entry.d.mts +42 -0
- package/dist/storage/jsonl-entry.d.ts +42 -0
- package/dist/storage/jsonl-entry.js +112 -0
- package/dist/storage/jsonl-entry.mjs +74 -0
- package/dist/storage/mongodb-entry.d.mts +40 -0
- package/dist/storage/mongodb-entry.d.ts +40 -0
- package/dist/storage/mongodb-entry.js +114 -0
- package/dist/storage/mongodb-entry.mjs +86 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
- package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
- package/docs/IMPLEMENTATION.md +333 -0
- package/docs/PLAN.md +153 -0
- package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
- package/docs/SIDECAR_SERVER.md +184 -0
- package/package.json +62 -0
- package/scripts/adapt-dist-package.js +33 -0
- package/src/atomCreator.ts +76 -0
- package/src/createStore.ts +77 -0
- package/src/debouncer.ts +84 -0
- package/src/index.ts +35 -0
- package/src/server/cli.ts +62 -0
- package/src/server/entityIdCache.ts +57 -0
- package/src/server/entry.ts +12 -0
- package/src/server/handlers.ts +271 -0
- package/src/server/index.ts +182 -0
- package/src/server/router.ts +74 -0
- package/src/server.ts +5 -0
- package/src/storage/adapters/remoteStorage.ts +70 -0
- package/src/storage/base.ts +28 -0
- package/src/storage/index.ts +9 -0
- package/src/storage/jsonl-entry.ts +9 -0
- package/src/storage/jsonl.ts +111 -0
- package/src/storage/memory.ts +49 -0
- package/src/storage/mongodb-entry.ts +9 -0
- package/src/storage/mongodb.ts +132 -0
- package/src/storage/protocol.ts +170 -0
- package/src/storage/transport.ts +95 -0
- package/src/types.ts +76 -0
- package/src/useStore.ts +83 -0
- package/tests/atomCreator.test.ts +153 -0
- package/tests/createStore.test.ts +126 -0
- package/tests/debouncer.test.ts +125 -0
- package/tests/server.test.ts +158 -0
- package/tests/storage/jsonl.test.ts +132 -0
- package/tests/storage/memory.test.ts +101 -0
- package/tests/storage/mongodb.test.ts +40 -0
- package/tests/storage/remoteStorage.test.ts +126 -0
- package/tests/useStore.test.tsx +147 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +53 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Client-side adapter that calls the embedded sidecar server.
|
|
5
|
+
* Uses HttpTransport and RestStorageProtocol for HTTP operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Storage, StorageAdapter } from '../base';
|
|
9
|
+
import { HttpTransport, type TransportAdapter } from '../transport';
|
|
10
|
+
import { RestStorageProtocol, createStorageWithProtocol, type StorageHttpProtocol } from '../protocol';
|
|
11
|
+
|
|
12
|
+
export interface RemoteStorageOptions {
|
|
13
|
+
baseUrl?: string; // Default: 'http://localhost:3847'
|
|
14
|
+
entityId?: string; // Default entityId for all requests
|
|
15
|
+
transport?: TransportAdapter; // Custom transport (default: HttpTransport)
|
|
16
|
+
protocol?: StorageHttpProtocol; // Custom protocol (default: RestStorageProtocol)
|
|
17
|
+
// Storage backend configuration (sent to BFF)
|
|
18
|
+
backend?: 'mongodb' | 'jsonl';
|
|
19
|
+
mongoUrl?: string;
|
|
20
|
+
mongoDb?: string;
|
|
21
|
+
jsonlDir?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a remote storage adapter that connects to the embedded sidecar server.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { createStore, remoteStorage } from 'bff-store';
|
|
30
|
+
*
|
|
31
|
+
* // Connect to default server (localhost:3847)
|
|
32
|
+
* const adapter = remoteStorage();
|
|
33
|
+
*
|
|
34
|
+
* // Or with custom server URL
|
|
35
|
+
* const adapter = remoteStorage({ baseUrl: 'http://localhost:3847' });
|
|
36
|
+
*
|
|
37
|
+
* // With entityId
|
|
38
|
+
* const adapter = remoteStorage({ entityId: 'user-123' });
|
|
39
|
+
*
|
|
40
|
+
* const store = createStore('user-123', config, { storage: adapter });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function remoteStorage(options: RemoteStorageOptions = {}): StorageAdapter {
|
|
44
|
+
const baseUrl = options.baseUrl ?? 'http://localhost:3847';
|
|
45
|
+
const transport = options.transport ?? new HttpTransport();
|
|
46
|
+
const entityId = { current: options.entityId };
|
|
47
|
+
|
|
48
|
+
// Backend config for BFF routing
|
|
49
|
+
const backendConfig = {
|
|
50
|
+
backend: options.backend,
|
|
51
|
+
mongoUrl: options.mongoUrl,
|
|
52
|
+
mongoDb: options.mongoDb,
|
|
53
|
+
jsonlDir: options.jsonlDir,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const protocol = options.protocol ?? new RestStorageProtocol(baseUrl, entityId.current, backendConfig);
|
|
57
|
+
const storage = createStorageWithProtocol(transport, protocol);
|
|
58
|
+
|
|
59
|
+
const adapter: StorageAdapter = {
|
|
60
|
+
storage,
|
|
61
|
+
name: 'remote',
|
|
62
|
+
setEntityId(id: string) {
|
|
63
|
+
entityId.current = id;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return adapter;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { remoteStorage as createRemoteStorage };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// ========================================
|
|
2
|
+
// Storage Interface
|
|
3
|
+
// ========================================
|
|
4
|
+
|
|
5
|
+
export interface Storage {
|
|
6
|
+
get<T>(key: string): Promise<T | null>;
|
|
7
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
8
|
+
remove(key: string): Promise<void>;
|
|
9
|
+
/** Optional: get multiple keys at once for batch loading */
|
|
10
|
+
getMultiple?<T>(keys: string[]): Promise<Map<string, T>>;
|
|
11
|
+
/** Optional: set multiple keys at once for batch saving */
|
|
12
|
+
setMultiple?<T>(entries: Map<string, T>): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ========================================
|
|
16
|
+
// Storage Adapter Factory Types
|
|
17
|
+
// ========================================
|
|
18
|
+
|
|
19
|
+
export interface StorageAdapter {
|
|
20
|
+
storage: Storage;
|
|
21
|
+
name: string;
|
|
22
|
+
/** Optional: set the entityId for multi-tenant storage */
|
|
23
|
+
setEntityId?(entityId: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type StorageFactory<T = unknown> = (options?: T) => StorageAdapter;
|
|
27
|
+
|
|
28
|
+
export type AsyncStorageFactory<T = unknown> = (options: T) => Promise<StorageAdapter>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { memoryStorage, createMemoryStorage } from './memory';
|
|
2
|
+
export { jsonlStorage, createJsonlStorage } from './jsonl';
|
|
3
|
+
export { mongodbStorage, createMongoStorage } from './mongodb';
|
|
4
|
+
export { remoteStorage, createRemoteStorage } from './adapters/remoteStorage';
|
|
5
|
+
export { HttpTransport, createStorageFromTransport } from './transport';
|
|
6
|
+
export { RestStorageProtocol, createStorageWithProtocol } from './protocol';
|
|
7
|
+
export type { TransportAdapter } from './transport';
|
|
8
|
+
export type { StorageHttpProtocol } from './protocol';
|
|
9
|
+
export type { Storage, StorageAdapter, StorageFactory, AsyncStorageFactory } from './base';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL storage adapter entry point
|
|
3
|
+
* Import from 'bff-store/jsonl' instead of 'bff-store'
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* import { jsonlStorage } from 'bff-store/jsonl';
|
|
7
|
+
*/
|
|
8
|
+
export { jsonlStorage, createJsonlStorage } from './jsonl';
|
|
9
|
+
export type { JsonlStorageOptions } from '../types';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { Storage, StorageAdapter, StorageFactory } from './base';
|
|
4
|
+
import type { JsonlStorageOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
interface JsonlEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
value: unknown;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface JsonlStorageInstance extends StorageAdapter {
|
|
13
|
+
storage: Storage;
|
|
14
|
+
setEntityId(id: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* JSONL file storage adapter
|
|
19
|
+
* Stores data as {dir}/{entityId}/{key}.jsonl
|
|
20
|
+
* Each line is a JSON object with key, value, and timestamp
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* const adapter = jsonlStorage({ dir: './sessions' });
|
|
24
|
+
* adapter.setEntityId('user-123');
|
|
25
|
+
* const store = createStore('user-123', config, { storage: adapter.storage });
|
|
26
|
+
*/
|
|
27
|
+
export function jsonlStorage(options?: JsonlStorageOptions): JsonlStorageInstance {
|
|
28
|
+
const baseDir = options?.dir ?? './sessions';
|
|
29
|
+
let entityId: string = 'default';
|
|
30
|
+
|
|
31
|
+
function getFilePath(eId: string, key: string): string {
|
|
32
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
33
|
+
return path.join(baseDir, eId, `${safeKey}.jsonl`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const storage: Storage = {
|
|
37
|
+
async get<T>(key: string): Promise<T | null> {
|
|
38
|
+
if (!entityId) return null;
|
|
39
|
+
|
|
40
|
+
const filePath = getFilePath(entityId, key);
|
|
41
|
+
if (!fs.existsSync(filePath)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
47
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
48
|
+
|
|
49
|
+
if (lines.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
// Read the last line for the latest value
|
|
52
|
+
const lastLine = lines[lines.length - 1];
|
|
53
|
+
const entry: JsonlEntry = JSON.parse(lastLine);
|
|
54
|
+
return entry.value as T;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
61
|
+
if (!entityId) return;
|
|
62
|
+
|
|
63
|
+
const filePath = getFilePath(entityId, key);
|
|
64
|
+
const dir = path.dirname(filePath);
|
|
65
|
+
|
|
66
|
+
// Ensure directory exists
|
|
67
|
+
if (!fs.existsSync(dir)) {
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entry: JsonlEntry = {
|
|
72
|
+
key,
|
|
73
|
+
value,
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const line = JSON.stringify(entry) + '\n';
|
|
78
|
+
fs.appendFileSync(filePath, line, 'utf-8');
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async remove(key: string): Promise<void> {
|
|
82
|
+
if (!entityId) return;
|
|
83
|
+
|
|
84
|
+
const filePath = getFilePath(entityId, key);
|
|
85
|
+
if (fs.existsSync(filePath)) {
|
|
86
|
+
fs.unlinkSync(filePath);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async getMultiple<T>(keys: string[]): Promise<Map<string, T>> {
|
|
91
|
+
const result = new Map<string, T>();
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const value = await this.get<T>(key);
|
|
94
|
+
if (value !== null) {
|
|
95
|
+
result.set(key, value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
storage,
|
|
104
|
+
name: 'jsonl',
|
|
105
|
+
setEntityId(id: string) {
|
|
106
|
+
entityId = id;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const createJsonlStorage: StorageFactory<JsonlStorageOptions> = jsonlStorage;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Storage, StorageAdapter, StorageFactory } from './base';
|
|
2
|
+
import type { MemoryStorageOptions } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory storage adapter for development and testing
|
|
6
|
+
*/
|
|
7
|
+
export function memoryStorage(
|
|
8
|
+
_options?: MemoryStorageOptions
|
|
9
|
+
): StorageAdapter {
|
|
10
|
+
const store = new Map<string, unknown>();
|
|
11
|
+
|
|
12
|
+
const storage: Storage = {
|
|
13
|
+
async get<T>(key: string): Promise<T | null> {
|
|
14
|
+
return (store.get(key) as T) ?? null;
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
18
|
+
store.set(key, value);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async remove(key: string): Promise<void> {
|
|
22
|
+
store.delete(key);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async getMultiple<T>(keys: string[]): Promise<Map<string, T>> {
|
|
26
|
+
const result = new Map<string, T>();
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
const value = store.get(key) as T;
|
|
29
|
+
if (value !== undefined) {
|
|
30
|
+
result.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async setMultiple<T>(entries: Map<string, T>): Promise<void> {
|
|
37
|
+
entries.forEach((value, key) => {
|
|
38
|
+
store.set(key, value);
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
storage,
|
|
45
|
+
name: 'memory',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const createMemoryStorage: StorageFactory<MemoryStorageOptions> = memoryStorage;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB storage adapter entry point
|
|
3
|
+
* Import from 'bff-store/mongodb' instead of 'bff-store'
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* import { mongodbStorage } from 'bff-store/mongodb';
|
|
7
|
+
*/
|
|
8
|
+
export { mongodbStorage, createMongoStorage } from './mongodb';
|
|
9
|
+
export type { MongoStorageOptions } from '../types';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { MongoClient, Collection, Db } from 'mongodb';
|
|
2
|
+
import { Storage, StorageAdapter, AsyncStorageFactory } from './base';
|
|
3
|
+
import type { MongoStorageOptions } from '../types';
|
|
4
|
+
|
|
5
|
+
interface MongoEntry {
|
|
6
|
+
key: string;
|
|
7
|
+
value: unknown;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
entityId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MongoStorageAdapter extends StorageAdapter {
|
|
13
|
+
client: MongoClient;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MongoDB storage adapter
|
|
19
|
+
* Stores data in collection: {database}.state_{entityId}
|
|
20
|
+
* Each entityId gets its own collection.
|
|
21
|
+
*/
|
|
22
|
+
export async function mongodbStorage(
|
|
23
|
+
options: MongoStorageOptions
|
|
24
|
+
): Promise<MongoStorageAdapter> {
|
|
25
|
+
const {
|
|
26
|
+
url,
|
|
27
|
+
database = 'jotai_state_store',
|
|
28
|
+
} = options;
|
|
29
|
+
|
|
30
|
+
const client = new MongoClient(url);
|
|
31
|
+
await client.connect();
|
|
32
|
+
|
|
33
|
+
const db: Db = client.db(database);
|
|
34
|
+
|
|
35
|
+
// Current entityId - defaults to 'default'
|
|
36
|
+
let currentEntityId = 'default';
|
|
37
|
+
|
|
38
|
+
// Get collection name for an entityId
|
|
39
|
+
function getCollectionName(eId: string): string {
|
|
40
|
+
return `state_${eId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get or create collection for an entityId
|
|
44
|
+
function getCollection(eId: string): Collection<MongoEntry> {
|
|
45
|
+
return db.collection<MongoEntry>(getCollectionName(eId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const storage: Storage = {
|
|
49
|
+
async get<T>(key: string): Promise<T | null> {
|
|
50
|
+
const collection = getCollection(currentEntityId);
|
|
51
|
+
|
|
52
|
+
const entry = await collection.findOne(
|
|
53
|
+
{ key },
|
|
54
|
+
{ sort: { timestamp: -1 } }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return entry ? (entry.value as T) : null;
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
61
|
+
const collection = getCollection(currentEntityId);
|
|
62
|
+
|
|
63
|
+
await collection.insertOne({
|
|
64
|
+
key,
|
|
65
|
+
value,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
entityId: currentEntityId,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async remove(key: string): Promise<void> {
|
|
72
|
+
const collection = getCollection(currentEntityId);
|
|
73
|
+
|
|
74
|
+
await collection.deleteMany({ key });
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async getMultiple<T>(keys: string[]): Promise<Map<string, T>> {
|
|
78
|
+
const collection = getCollection(currentEntityId);
|
|
79
|
+
|
|
80
|
+
const entries = await collection
|
|
81
|
+
.find({ key: { $in: keys } })
|
|
82
|
+
.sort({ timestamp: -1 })
|
|
83
|
+
.toArray();
|
|
84
|
+
|
|
85
|
+
const result = new Map<string, T>();
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!seen.has(entry.key)) {
|
|
90
|
+
result.set(entry.key, entry.value as T);
|
|
91
|
+
seen.add(entry.key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async setMultiple<T>(entries: Map<string, T>): Promise<void> {
|
|
99
|
+
const collection = getCollection(currentEntityId);
|
|
100
|
+
|
|
101
|
+
const docs: MongoEntry[] = [];
|
|
102
|
+
entries.forEach((value, key) => {
|
|
103
|
+
docs.push({
|
|
104
|
+
key,
|
|
105
|
+
value,
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
entityId: currentEntityId,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (docs.length > 0) {
|
|
112
|
+
await collection.insertMany(docs);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const adapter: MongoStorageAdapter = {
|
|
118
|
+
storage,
|
|
119
|
+
name: 'mongodb',
|
|
120
|
+
client,
|
|
121
|
+
setEntityId(id: string) {
|
|
122
|
+
currentEntityId = id;
|
|
123
|
+
},
|
|
124
|
+
async close() {
|
|
125
|
+
await client.close();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return adapter;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const createMongoStorage: AsyncStorageFactory<MongoStorageOptions> = mongodbStorage;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage HTTP Protocol Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the request/response contract for storage operations over HTTP.
|
|
5
|
+
* This allows the protocol to be implemented differently without changing
|
|
6
|
+
* the storage semantics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TransportAdapter } from './transport';
|
|
10
|
+
import type { BackendConfig } from '../types';
|
|
11
|
+
export type { BackendConfig } from '../types';
|
|
12
|
+
|
|
13
|
+
export interface StorageHttpProtocol {
|
|
14
|
+
/** Build URL for get operation */
|
|
15
|
+
buildGetUrl(key: string): string;
|
|
16
|
+
/** Build URL for set operation */
|
|
17
|
+
buildSetUrl(key: string): string;
|
|
18
|
+
/** Build URL for delete operation */
|
|
19
|
+
buildDeleteUrl(key: string): string;
|
|
20
|
+
/** Build URL for batch get */
|
|
21
|
+
buildBatchGetUrl(): string;
|
|
22
|
+
/** Build URL for batch set */
|
|
23
|
+
buildBatchSetUrl(): string;
|
|
24
|
+
/** Get backend config */
|
|
25
|
+
getBackendConfig(): BackendConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default protocol implementation using standard REST paths
|
|
30
|
+
*/
|
|
31
|
+
export class RestStorageProtocol implements StorageHttpProtocol {
|
|
32
|
+
constructor(
|
|
33
|
+
private baseUrl: string,
|
|
34
|
+
private entityId?: string | { current?: string },
|
|
35
|
+
private backendConfig: BackendConfig = {}
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
private getEntityId(): string | undefined {
|
|
39
|
+
return typeof this.entityId === 'object' ? this.entityId.current : this.entityId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private appendBackendParams(params: URLSearchParams): void {
|
|
43
|
+
if (this.backendConfig.backend) {
|
|
44
|
+
params.set('backend', this.backendConfig.backend);
|
|
45
|
+
}
|
|
46
|
+
if (this.backendConfig.mongoUrl) {
|
|
47
|
+
params.set('mongoUrl', this.backendConfig.mongoUrl);
|
|
48
|
+
}
|
|
49
|
+
if (this.backendConfig.mongoDb) {
|
|
50
|
+
params.set('mongoDb', this.backendConfig.mongoDb);
|
|
51
|
+
}
|
|
52
|
+
if (this.backendConfig.jsonlDir) {
|
|
53
|
+
params.set('jsonlDir', this.backendConfig.jsonlDir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private buildUrl(path: string, key: string): string {
|
|
58
|
+
const url = `${this.baseUrl}${path}${encodeURIComponent(key)}`;
|
|
59
|
+
const params = new URLSearchParams();
|
|
60
|
+
const eid = this.getEntityId();
|
|
61
|
+
if (eid) {
|
|
62
|
+
params.set('entityId', eid);
|
|
63
|
+
}
|
|
64
|
+
this.appendBackendParams(params);
|
|
65
|
+
const queryString = params.toString();
|
|
66
|
+
return queryString ? `${url}?${queryString}` : url;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
buildGetUrl(key: string): string {
|
|
70
|
+
return this.buildUrl('/storage/get/', key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
buildSetUrl(key: string): string {
|
|
74
|
+
return this.buildUrl('/storage/set/', key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
buildDeleteUrl(key: string): string {
|
|
78
|
+
return this.buildUrl('/storage/delete/', key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
buildBatchGetUrl(): string {
|
|
82
|
+
const url = `${this.baseUrl}/storage/batch-get`;
|
|
83
|
+
const params = new URLSearchParams();
|
|
84
|
+
const eid = this.getEntityId();
|
|
85
|
+
if (eid) {
|
|
86
|
+
params.set('entityId', eid);
|
|
87
|
+
}
|
|
88
|
+
this.appendBackendParams(params);
|
|
89
|
+
const queryString = params.toString();
|
|
90
|
+
return queryString ? `${url}?${queryString}` : url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
buildBatchSetUrl(): string {
|
|
94
|
+
const url = `${this.baseUrl}/storage/batch-set`;
|
|
95
|
+
const params = new URLSearchParams();
|
|
96
|
+
const eid = this.getEntityId();
|
|
97
|
+
if (eid) {
|
|
98
|
+
params.set('entityId', eid);
|
|
99
|
+
}
|
|
100
|
+
this.appendBackendParams(params);
|
|
101
|
+
const queryString = params.toString();
|
|
102
|
+
return queryString ? `${url}?${queryString}` : url;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getBackendConfig(): BackendConfig {
|
|
106
|
+
return this.backendConfig;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
withEntityId(entityId: string): RestStorageProtocol {
|
|
110
|
+
return new RestStorageProtocol(this.baseUrl, entityId, this.backendConfig);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a storage that uses a specific protocol
|
|
116
|
+
*/
|
|
117
|
+
export function createStorageWithProtocol(
|
|
118
|
+
transport: TransportAdapter,
|
|
119
|
+
protocol: StorageHttpProtocol
|
|
120
|
+
): {
|
|
121
|
+
get<T>(key: string): Promise<T | null>;
|
|
122
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
123
|
+
remove(key: string): Promise<void>;
|
|
124
|
+
getMultiple<T>(keys: string[]): Promise<Map<string, T>>;
|
|
125
|
+
setMultiple<T>(entries: Map<string, T>): Promise<void>;
|
|
126
|
+
} {
|
|
127
|
+
// Get backend config from protocol if available
|
|
128
|
+
const backendConfig = 'getBackendConfig' in protocol
|
|
129
|
+
? protocol.getBackendConfig()
|
|
130
|
+
: {};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
async get<T>(key: string): Promise<T | null> {
|
|
134
|
+
try {
|
|
135
|
+
const res = await transport.get<{ value: T }>(protocol.buildGetUrl(key));
|
|
136
|
+
return res.value;
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
if (err.message.includes('failed')) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
146
|
+
const body: Record<string, unknown> = { value, ...backendConfig };
|
|
147
|
+
await transport.post(protocol.buildSetUrl(key), body);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async remove(key: string): Promise<void> {
|
|
151
|
+
await transport.delete(protocol.buildDeleteUrl(key));
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async getMultiple<T>(keys: string[]): Promise<Map<string, T>> {
|
|
155
|
+
const res = await transport.post<{ keys: string[] }, { entries: Record<string, T> }>(
|
|
156
|
+
protocol.buildBatchGetUrl(),
|
|
157
|
+
{ keys, ...backendConfig }
|
|
158
|
+
);
|
|
159
|
+
return new Map(Object.entries(res.entries));
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async setMultiple<T>(entries: Map<string, T>): Promise<void> {
|
|
163
|
+
const obj: Record<string, T> = {};
|
|
164
|
+
entries.forEach((value, key) => {
|
|
165
|
+
obj[key] = value;
|
|
166
|
+
});
|
|
167
|
+
await transport.post(protocol.buildBatchSetUrl(), { entries: obj, ...backendConfig });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport Adapter Interface
|
|
3
|
+
*
|
|
4
|
+
* Abstracts the transport layer for remote storage operations.
|
|
5
|
+
* Can be implemented as HTTP, WebSocket, or other protocols.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface TransportAdapter {
|
|
9
|
+
/** GET request */
|
|
10
|
+
get<T>(url: string): Promise<T>;
|
|
11
|
+
/** POST request with JSON body */
|
|
12
|
+
post<T, R>(url: string, body: T): Promise<R>;
|
|
13
|
+
/** DELETE request */
|
|
14
|
+
delete(url: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* HTTP Transport Adapter using fetch
|
|
19
|
+
*/
|
|
20
|
+
export class HttpTransport implements TransportAdapter {
|
|
21
|
+
async get<T>(url: string): Promise<T> {
|
|
22
|
+
const res = await fetch(url);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(`GET ${url} failed: ${res.statusText}`);
|
|
25
|
+
}
|
|
26
|
+
return res.json();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async post<T, R>(url: string, body: T): Promise<R> {
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
throw new Error(`POST ${url} failed: ${res.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async delete(url: string): Promise<void> {
|
|
42
|
+
const res = await fetch(url, { method: 'DELETE' });
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw new Error(`DELETE ${url} failed: ${res.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a StorageAdapter that wraps a TransportAdapter and base URL
|
|
51
|
+
*/
|
|
52
|
+
import type { Storage, StorageAdapter } from './base';
|
|
53
|
+
|
|
54
|
+
export function createStorageFromTransport(
|
|
55
|
+
transport: TransportAdapter,
|
|
56
|
+
baseUrl: string
|
|
57
|
+
): Storage {
|
|
58
|
+
return {
|
|
59
|
+
async get<T>(key: string): Promise<T | null> {
|
|
60
|
+
try {
|
|
61
|
+
const res = await transport.get<{ value: T }>(`${baseUrl}/storage/get/${encodeURIComponent(key)}`);
|
|
62
|
+
return res.value;
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
if (err.message.includes('failed')) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
72
|
+
await transport.post(`${baseUrl}/storage/set/${encodeURIComponent(key)}`, { value });
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async remove(key: string): Promise<void> {
|
|
76
|
+
await transport.delete(`${baseUrl}/storage/delete/${encodeURIComponent(key)}`);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getMultiple<T>(keys: string[]): Promise<Map<string, T>> {
|
|
80
|
+
const res = await transport.post<{ keys: string[] }, { entries: Record<string, T> }>(
|
|
81
|
+
`${baseUrl}/storage/batch-get`,
|
|
82
|
+
{ keys }
|
|
83
|
+
);
|
|
84
|
+
return new Map(Object.entries(res.entries));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async setMultiple<T>(entries: Map<string, T>): Promise<void> {
|
|
88
|
+
const obj: Record<string, T> = {};
|
|
89
|
+
entries.forEach((value, key) => {
|
|
90
|
+
obj[key] = value;
|
|
91
|
+
});
|
|
92
|
+
await transport.post(`${baseUrl}/storage/batch-set`, { entries: obj });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|