@lobu/core 3.0.12 → 3.0.13
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/package.json +3 -3
- package/src/__tests__/encryption.test.ts +103 -0
- package/src/__tests__/fixtures/factories.ts +76 -0
- package/src/__tests__/fixtures/index.ts +9 -0
- package/src/__tests__/fixtures/mock-fetch.ts +32 -0
- package/src/__tests__/fixtures/mock-queue.ts +50 -0
- package/src/__tests__/fixtures/mock-redis.ts +300 -0
- package/src/__tests__/retry.test.ts +134 -0
- package/src/__tests__/sanitize.test.ts +158 -0
- package/src/agent-policy.ts +207 -0
- package/src/agent-store.ts +220 -0
- package/src/api-types.ts +256 -0
- package/src/command-registry.ts +73 -0
- package/src/constants.ts +60 -0
- package/src/errors.ts +220 -0
- package/src/index.ts +131 -0
- package/src/integration-types.ts +26 -0
- package/src/logger.ts +248 -0
- package/src/modules.ts +184 -0
- package/src/otel.ts +307 -0
- package/src/plugin-types.ts +46 -0
- package/src/provider-config-types.ts +54 -0
- package/src/redis/base-store.ts +200 -0
- package/src/sentry.ts +56 -0
- package/src/trace.ts +32 -0
- package/src/types.ts +440 -0
- package/src/utils/encryption.ts +78 -0
- package/src/utils/env.ts +50 -0
- package/src/utils/json.ts +37 -0
- package/src/utils/lock.ts +75 -0
- package/src/utils/mcp-tool-instructions.ts +5 -0
- package/src/utils/retry.ts +91 -0
- package/src/utils/sanitize.ts +127 -0
- package/src/worker/auth.ts +100 -0
- package/src/worker/transport.ts +107 -0
- package/tsconfig.json +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobu/core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.13",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Core types and utilities for Lobu agent platform",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
|
-
"bun": "./
|
|
11
|
+
"bun": "./src/index.ts",
|
|
12
12
|
"import": "./dist/index.js",
|
|
13
13
|
"require": "./dist/index.js"
|
|
14
14
|
},
|
|
15
15
|
"./testing": {
|
|
16
|
-
"bun": "./
|
|
16
|
+
"bun": "./src/__tests__/fixtures/index.ts"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { decrypt, encrypt } from "../utils/encryption";
|
|
3
|
+
|
|
4
|
+
describe("encryption", () => {
|
|
5
|
+
let originalKey: string | undefined;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
originalKey = process.env.ENCRYPTION_KEY;
|
|
9
|
+
// 32-byte hex key
|
|
10
|
+
process.env.ENCRYPTION_KEY =
|
|
11
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (originalKey !== undefined) {
|
|
16
|
+
process.env.ENCRYPTION_KEY = originalKey;
|
|
17
|
+
} else {
|
|
18
|
+
delete process.env.ENCRYPTION_KEY;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("encrypt/decrypt round-trip preserves plaintext", () => {
|
|
23
|
+
const plaintext = "hello world";
|
|
24
|
+
const encrypted = encrypt(plaintext);
|
|
25
|
+
expect(decrypt(encrypted)).toBe(plaintext);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("encrypt/decrypt works with empty string", () => {
|
|
29
|
+
const encrypted = encrypt("");
|
|
30
|
+
expect(decrypt(encrypted)).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("encrypt/decrypt works with unicode", () => {
|
|
34
|
+
const text = "こんにちは 🌍 émojis";
|
|
35
|
+
expect(decrypt(encrypt(text))).toBe(text);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("encrypt/decrypt works with long text", () => {
|
|
39
|
+
const text = "x".repeat(10_000);
|
|
40
|
+
expect(decrypt(encrypt(text))).toBe(text);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("each encryption produces different ciphertext (random IV)", () => {
|
|
44
|
+
const plaintext = "same input";
|
|
45
|
+
const a = encrypt(plaintext);
|
|
46
|
+
const b = encrypt(plaintext);
|
|
47
|
+
expect(a).not.toBe(b);
|
|
48
|
+
// Both should still decrypt to the same value
|
|
49
|
+
expect(decrypt(a)).toBe(plaintext);
|
|
50
|
+
expect(decrypt(b)).toBe(plaintext);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("encrypted format is iv:tag:ciphertext (3 hex parts)", () => {
|
|
54
|
+
const encrypted = encrypt("test");
|
|
55
|
+
const parts = encrypted.split(":");
|
|
56
|
+
expect(parts).toHaveLength(3);
|
|
57
|
+
// Each part should be valid hex
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
expect(part).toMatch(/^[0-9a-f]+$/);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("decrypt throws on invalid format (wrong number of parts)", () => {
|
|
64
|
+
expect(() => decrypt("only-one-part")).toThrow("Invalid encrypted format");
|
|
65
|
+
expect(() => decrypt("a:b")).toThrow("Invalid encrypted format");
|
|
66
|
+
expect(() => decrypt("a:b:c:d")).toThrow("Invalid encrypted format");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("decrypt throws on tampered ciphertext", () => {
|
|
70
|
+
const encrypted = encrypt("secret");
|
|
71
|
+
const parts = encrypted.split(":");
|
|
72
|
+
// Tamper with the ciphertext
|
|
73
|
+
parts[2] = "ff".repeat(parts[2]!.length / 2);
|
|
74
|
+
expect(() => decrypt(parts.join(":"))).toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("throws when ENCRYPTION_KEY is missing", () => {
|
|
78
|
+
delete process.env.ENCRYPTION_KEY;
|
|
79
|
+
expect(() => encrypt("test")).toThrow(
|
|
80
|
+
"ENCRYPTION_KEY environment variable is required"
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws when ENCRYPTION_KEY has wrong length", () => {
|
|
85
|
+
process.env.ENCRYPTION_KEY = "too-short";
|
|
86
|
+
expect(() => encrypt("test")).toThrow("base64 or hex encoded 32-byte key");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("accepts base64-encoded 32-byte key", () => {
|
|
90
|
+
// 32 bytes → 44 chars in base64
|
|
91
|
+
process.env.ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
92
|
+
const encrypted = encrypt("base64 key test");
|
|
93
|
+
expect(decrypt(encrypted)).toBe("base64 key test");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rejects utf8 32-byte key (only base64 and hex accepted)", () => {
|
|
97
|
+
process.env.ENCRYPTION_KEY = "abcdefghijklmnopqrstuvwxyz012345";
|
|
98
|
+
// 32 ASCII chars = 32 bytes in utf8, but utf8 keys are no longer accepted
|
|
99
|
+
expect(() => encrypt("utf8 key test")).toThrow(
|
|
100
|
+
"base64 or hex encoded 32-byte key"
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared factory functions for test data.
|
|
3
|
+
*
|
|
4
|
+
* All factories accept a partial override object so tests only specify
|
|
5
|
+
* the fields they care about while getting sensible defaults for the rest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { InstructionContext } from "../../types";
|
|
9
|
+
|
|
10
|
+
// Re-export WorkerConfig shape (worker package owns the interface).
|
|
11
|
+
// We duplicate a minimal version here to avoid a circular dependency.
|
|
12
|
+
export interface TestWorkerConfig {
|
|
13
|
+
sessionKey: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
channelId: string;
|
|
17
|
+
conversationId: string;
|
|
18
|
+
userPrompt: string;
|
|
19
|
+
responseChannel: string;
|
|
20
|
+
responseId: string;
|
|
21
|
+
platform: string;
|
|
22
|
+
agentOptions: string;
|
|
23
|
+
teamId?: string;
|
|
24
|
+
workspace: { baseDirectory: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createWorkerConfig(
|
|
28
|
+
overrides: Partial<TestWorkerConfig> = {}
|
|
29
|
+
): TestWorkerConfig {
|
|
30
|
+
return {
|
|
31
|
+
sessionKey: "test-session-key",
|
|
32
|
+
userId: "U1234567890",
|
|
33
|
+
agentId: "agent-test",
|
|
34
|
+
channelId: "C1234567890",
|
|
35
|
+
conversationId: "1234567890.123456",
|
|
36
|
+
userPrompt: Buffer.from("Test user prompt").toString("base64"),
|
|
37
|
+
responseChannel: "C1234567890",
|
|
38
|
+
responseId: "1234567890.123457",
|
|
39
|
+
platform: "slack",
|
|
40
|
+
agentOptions: JSON.stringify({
|
|
41
|
+
model: "claude-sonnet-4-20250514",
|
|
42
|
+
max_tokens: 8192,
|
|
43
|
+
}),
|
|
44
|
+
teamId: "T1234567890",
|
|
45
|
+
workspace: { baseDirectory: "/tmp/test-workspace" },
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createInstructionContext(
|
|
51
|
+
overrides: Partial<InstructionContext> = {}
|
|
52
|
+
): InstructionContext {
|
|
53
|
+
return {
|
|
54
|
+
userId: "U1234567890",
|
|
55
|
+
agentId: "agent-test",
|
|
56
|
+
sessionKey: "test-session-key",
|
|
57
|
+
workingDirectory: "/tmp/test-workspace/test-thread",
|
|
58
|
+
availableProjects: [],
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createMockJob(overrides: Record<string, any> = {}): {
|
|
64
|
+
id: string;
|
|
65
|
+
data: Record<string, any>;
|
|
66
|
+
} {
|
|
67
|
+
return {
|
|
68
|
+
id: `job-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
|
69
|
+
data: {
|
|
70
|
+
sessionKey: "test-session-key",
|
|
71
|
+
userId: "U123",
|
|
72
|
+
prompt: "test prompt",
|
|
73
|
+
...overrides,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createInstructionContext,
|
|
3
|
+
createMockJob,
|
|
4
|
+
createWorkerConfig,
|
|
5
|
+
type TestWorkerConfig,
|
|
6
|
+
} from "./factories";
|
|
7
|
+
export { mockFetch } from "./mock-fetch";
|
|
8
|
+
export { MockMessageQueue } from "./mock-queue";
|
|
9
|
+
export { MockRedisClient } from "./mock-redis";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified fetch mock for testing.
|
|
3
|
+
* Replaces TestHelpers.mockFetch from worker setup.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Install a mock global.fetch that returns pre-configured responses.
|
|
8
|
+
* Returns a cleanup function that restores the original fetch.
|
|
9
|
+
*
|
|
10
|
+
* @param responses - Map of URL → response body (JSON-serialisable).
|
|
11
|
+
* Unmatched URLs return `{ success: true }`.
|
|
12
|
+
*/
|
|
13
|
+
export function mockFetch(responses: Record<string, any> = {}): () => void {
|
|
14
|
+
const originalFetch = globalThis.fetch;
|
|
15
|
+
|
|
16
|
+
globalThis.fetch = (async (
|
|
17
|
+
url: string | URL | Request,
|
|
18
|
+
_options?: RequestInit
|
|
19
|
+
) => {
|
|
20
|
+
const urlString = url instanceof Request ? url.url : url.toString();
|
|
21
|
+
|
|
22
|
+
const body = responses[urlString] ?? { success: true };
|
|
23
|
+
return new Response(JSON.stringify(body), {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
});
|
|
27
|
+
}) as typeof fetch;
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified mock message queue for testing.
|
|
3
|
+
* Replaces MockMessageQueue from gateway setup.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MockRedisClient } from "./mock-redis";
|
|
7
|
+
|
|
8
|
+
export class MockMessageQueue {
|
|
9
|
+
private queues = new Map<string, any[]>();
|
|
10
|
+
private workers = new Map<string, (job: any) => Promise<void>>();
|
|
11
|
+
private redisClient = new MockRedisClient();
|
|
12
|
+
|
|
13
|
+
async createQueue(queueName: string): Promise<void> {
|
|
14
|
+
if (!this.queues.has(queueName)) {
|
|
15
|
+
this.queues.set(queueName, []);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async work(
|
|
20
|
+
queueName: string,
|
|
21
|
+
handler: (job: any) => Promise<void>,
|
|
22
|
+
_options?: { startPaused?: boolean }
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
this.workers.set(queueName, handler);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async addJob(queueName: string, job: any): Promise<void> {
|
|
28
|
+
const queue = this.queues.get(queueName);
|
|
29
|
+
if (!queue) throw new Error(`Queue ${queueName} does not exist`);
|
|
30
|
+
queue.push(job);
|
|
31
|
+
|
|
32
|
+
const handler = this.workers.get(queueName);
|
|
33
|
+
if (handler) await handler(job);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getRedisClient(): MockRedisClient {
|
|
37
|
+
return this.redisClient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Test helpers ---
|
|
41
|
+
|
|
42
|
+
getQueue(queueName: string): any[] | undefined {
|
|
43
|
+
return this.queues.get(queueName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearQueues(): void {
|
|
47
|
+
this.queues.clear();
|
|
48
|
+
this.workers.clear();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified in-memory Redis mock for testing.
|
|
3
|
+
*
|
|
4
|
+
* Replaces three duplicated implementations:
|
|
5
|
+
* - MockRedisClient in gateway setup.ts
|
|
6
|
+
* - FakeRedis in system-message-limiter.test.ts
|
|
7
|
+
* - queue mock getRedisClient
|
|
8
|
+
*
|
|
9
|
+
* Supports string, set, list, and hash operations with TTL tracking.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type SetMode = "NX" | undefined;
|
|
13
|
+
|
|
14
|
+
export class MockRedisClient {
|
|
15
|
+
private store = new Map<string, { value: string; ttl?: number }>();
|
|
16
|
+
private sets = new Map<string, Set<string>>();
|
|
17
|
+
private lists = new Map<string, string[]>();
|
|
18
|
+
private currentTime = Date.now();
|
|
19
|
+
|
|
20
|
+
// --- String operations ---
|
|
21
|
+
|
|
22
|
+
async exists(key: string): Promise<number> {
|
|
23
|
+
const entry = this.store.get(key);
|
|
24
|
+
if (!entry) return 0;
|
|
25
|
+
if (entry.ttl && entry.ttl < this.currentTime) {
|
|
26
|
+
this.store.delete(key);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async get(key: string): Promise<string | null> {
|
|
33
|
+
const entry = this.store.get(key);
|
|
34
|
+
if (!entry) return null;
|
|
35
|
+
if (entry.ttl && entry.ttl < this.currentTime) {
|
|
36
|
+
this.store.delete(key);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return entry.value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ioredis-compatible set with optional EX / NX flags.
|
|
44
|
+
* Supports: set(key, value), set(key, value, "EX", sec), set(key, value, "EX", sec, "NX")
|
|
45
|
+
*/
|
|
46
|
+
async set(
|
|
47
|
+
key: string,
|
|
48
|
+
value: string,
|
|
49
|
+
exTokenOrTtl?: "EX" | number,
|
|
50
|
+
exSeconds?: number,
|
|
51
|
+
mode?: SetMode
|
|
52
|
+
): Promise<"OK" | null> {
|
|
53
|
+
// NX check must be synchronous to avoid TOCTOU race
|
|
54
|
+
if (mode === "NX") {
|
|
55
|
+
const entry = this.store.get(key);
|
|
56
|
+
const alive = entry && (!entry.ttl || entry.ttl >= this.currentTime);
|
|
57
|
+
if (alive) return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let ttl: number | undefined;
|
|
61
|
+
if (exTokenOrTtl === "EX" && typeof exSeconds === "number") {
|
|
62
|
+
ttl = this.currentTime + exSeconds * 1000;
|
|
63
|
+
} else if (typeof exTokenOrTtl === "number") {
|
|
64
|
+
ttl = this.currentTime + exTokenOrTtl * 1000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.store.set(key, { value, ttl });
|
|
68
|
+
return "OK";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
|
|
72
|
+
const ttl = this.currentTime + ttlSeconds * 1000;
|
|
73
|
+
this.store.set(key, { value, ttl });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async incr(key: string): Promise<number> {
|
|
77
|
+
const current = await this.get(key);
|
|
78
|
+
const nextValue = (current ? Number.parseInt(current, 10) : 0) + 1;
|
|
79
|
+
const entry = this.store.get(key);
|
|
80
|
+
this.store.set(key, {
|
|
81
|
+
value: String(nextValue),
|
|
82
|
+
ttl: entry?.ttl,
|
|
83
|
+
});
|
|
84
|
+
return nextValue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async del(...keys: string[]): Promise<number> {
|
|
88
|
+
let deleted = 0;
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
const existed =
|
|
91
|
+
this.store.has(key) || this.sets.has(key) || this.lists.has(key);
|
|
92
|
+
this.store.delete(key);
|
|
93
|
+
this.sets.delete(key);
|
|
94
|
+
this.lists.delete(key);
|
|
95
|
+
if (existed) deleted++;
|
|
96
|
+
}
|
|
97
|
+
return deleted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getdel(key: string): Promise<string | null> {
|
|
101
|
+
const value = await this.get(key);
|
|
102
|
+
if (value !== null) {
|
|
103
|
+
await this.del(key);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async expire(key: string, seconds: number): Promise<number> {
|
|
109
|
+
if (this.store.has(key)) {
|
|
110
|
+
const entry = this.store.get(key)!;
|
|
111
|
+
entry.ttl = this.currentTime + seconds * 1000;
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async ttl(key: string): Promise<number> {
|
|
118
|
+
const entry = this.store.get(key);
|
|
119
|
+
if (!entry) return -2;
|
|
120
|
+
if (!entry.ttl) return -1;
|
|
121
|
+
if (entry.ttl < this.currentTime) {
|
|
122
|
+
this.store.delete(key);
|
|
123
|
+
return -2;
|
|
124
|
+
}
|
|
125
|
+
return Math.max(0, Math.ceil((entry.ttl - this.currentTime) / 1000));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Set operations ---
|
|
129
|
+
|
|
130
|
+
async sadd(key: string, ...members: string[]): Promise<number> {
|
|
131
|
+
if (!this.sets.has(key)) this.sets.set(key, new Set());
|
|
132
|
+
const set = this.sets.get(key)!;
|
|
133
|
+
let added = 0;
|
|
134
|
+
for (const m of members) {
|
|
135
|
+
if (!set.has(m)) {
|
|
136
|
+
set.add(m);
|
|
137
|
+
added++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return added;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async srem(key: string, ...members: string[]): Promise<number> {
|
|
144
|
+
const set = this.sets.get(key);
|
|
145
|
+
if (!set) return 0;
|
|
146
|
+
let removed = 0;
|
|
147
|
+
for (const m of members) {
|
|
148
|
+
if (set.delete(m)) removed++;
|
|
149
|
+
}
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async smembers(key: string): Promise<string[]> {
|
|
154
|
+
const set = this.sets.get(key);
|
|
155
|
+
return set ? Array.from(set) : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async sismember(key: string, member: string): Promise<number> {
|
|
159
|
+
const set = this.sets.get(key);
|
|
160
|
+
return set?.has(member) ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- List operations ---
|
|
164
|
+
|
|
165
|
+
async rpush(key: string, ...values: string[]): Promise<number> {
|
|
166
|
+
if (!this.lists.has(key)) this.lists.set(key, []);
|
|
167
|
+
const list = this.lists.get(key)!;
|
|
168
|
+
list.push(...values);
|
|
169
|
+
return list.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
|
173
|
+
const list = this.lists.get(key);
|
|
174
|
+
if (!list) return [];
|
|
175
|
+
const end = stop === -1 ? list.length : stop + 1;
|
|
176
|
+
return list.slice(start, end);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Scan ---
|
|
180
|
+
|
|
181
|
+
async scan(
|
|
182
|
+
_cursor: string,
|
|
183
|
+
...args: (string | number)[]
|
|
184
|
+
): Promise<[string, string[]]> {
|
|
185
|
+
// Extract pattern from args: "MATCH", pattern, "COUNT", count
|
|
186
|
+
let pattern = "*";
|
|
187
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
188
|
+
if (String(args[i]).toUpperCase() === "MATCH") {
|
|
189
|
+
pattern = String(args[i + 1]);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const allKeys = new Set<string>();
|
|
195
|
+
for (const key of this.store.keys()) allKeys.add(key);
|
|
196
|
+
for (const key of this.sets.keys()) allKeys.add(key);
|
|
197
|
+
for (const key of this.lists.keys()) allKeys.add(key);
|
|
198
|
+
|
|
199
|
+
const matching: string[] = [];
|
|
200
|
+
for (const key of allKeys) {
|
|
201
|
+
if (pattern === "*") {
|
|
202
|
+
matching.push(key);
|
|
203
|
+
} else if (pattern.endsWith("*")) {
|
|
204
|
+
const prefix = pattern.slice(0, -1);
|
|
205
|
+
if (key.startsWith(prefix)) matching.push(key);
|
|
206
|
+
} else if (key === pattern) {
|
|
207
|
+
matching.push(key);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return ["0", matching];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Batch get ---
|
|
215
|
+
|
|
216
|
+
async mget(...keys: string[]): Promise<(string | null)[]> {
|
|
217
|
+
return Promise.all(keys.map((key) => this.get(key)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Watch / Unwatch (no-ops) ---
|
|
221
|
+
|
|
222
|
+
async watch(..._keys: string[]): Promise<"OK"> {
|
|
223
|
+
return "OK";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async unwatch(): Promise<"OK"> {
|
|
227
|
+
return "OK";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Multi ---
|
|
231
|
+
|
|
232
|
+
multi(): {
|
|
233
|
+
set(key: string, value: string): any;
|
|
234
|
+
exec(): Promise<[null, string][]>;
|
|
235
|
+
} {
|
|
236
|
+
const pending: Array<{ key: string; value: string }> = [];
|
|
237
|
+
const self = this;
|
|
238
|
+
const chain = {
|
|
239
|
+
set(key: string, value: string) {
|
|
240
|
+
pending.push({ key, value });
|
|
241
|
+
return chain;
|
|
242
|
+
},
|
|
243
|
+
async exec(): Promise<[null, string][]> {
|
|
244
|
+
const results: [null, string][] = [];
|
|
245
|
+
for (const op of pending) {
|
|
246
|
+
self.store.set(op.key, { value: op.value });
|
|
247
|
+
results.push([null, "OK"]);
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
return chain;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Pipeline ---
|
|
256
|
+
|
|
257
|
+
pipeline(): {
|
|
258
|
+
setex(key: string, ttl: number, value: string): any;
|
|
259
|
+
del(...keys: string[]): any;
|
|
260
|
+
exec(): Promise<Array<[null, any]>>;
|
|
261
|
+
} {
|
|
262
|
+
const ops: Array<() => Promise<any>> = [];
|
|
263
|
+
const self = this;
|
|
264
|
+
const chain = {
|
|
265
|
+
setex(key: string, ttl: number, value: string) {
|
|
266
|
+
ops.push(() => self.setex(key, ttl, value));
|
|
267
|
+
return chain;
|
|
268
|
+
},
|
|
269
|
+
del(...keys: string[]) {
|
|
270
|
+
ops.push(() => self.del(...keys));
|
|
271
|
+
return chain;
|
|
272
|
+
},
|
|
273
|
+
async exec(): Promise<Array<[null, any]>> {
|
|
274
|
+
const results: Array<[null, any]> = [];
|
|
275
|
+
for (const op of ops) {
|
|
276
|
+
const result = await op();
|
|
277
|
+
results.push([null, result ?? "OK"]);
|
|
278
|
+
}
|
|
279
|
+
return results;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
return chain;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- Test helpers ---
|
|
286
|
+
|
|
287
|
+
advanceTime(ms: number): void {
|
|
288
|
+
this.currentTime += ms;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
has(key: string): boolean {
|
|
292
|
+
return this.store.has(key);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
clear(): void {
|
|
296
|
+
this.store.clear();
|
|
297
|
+
this.sets.clear();
|
|
298
|
+
this.lists.clear();
|
|
299
|
+
}
|
|
300
|
+
}
|