diff --git a/packages/core/package.json b/packages/core/package.json index 3ebaa115..db153220 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "@rollup/pluginutils": "^5.1.0", "@types/express": "^4.17.23", "@types/lodash": "^4.17.10", + "@types/memjs": "^1.3.3", "@types/node": "^20.19.0", "cross-env": "^7.0.3", "ctix": "^2.7.1", @@ -51,7 +52,8 @@ "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript-paths": "^1.5.0", "typedoc": "^0.28.5", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "vitest": "^3.2.3" }, "dependencies": { "@anthropic-ai/sdk": "^0.56.0", @@ -88,6 +90,7 @@ "js-yaml": "^4.1.0", "jsonrepair": "^3.8.0", "lodash": "^4.17.21", + "memjs": "^1.3.2", "mime": "^4.0.3", "mysql2": "^3.11.3", "oauth-1.0a": "^2.2.6", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bbc1c2db..fd0eaf05 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -181,6 +181,7 @@ export * from './subsystems/LLMManager/ModelsProvider.service/connectors/JSONMod export * from './subsystems/MemoryManager/Cache.service/connectors/LocalStorageCache.class'; export * from './subsystems/MemoryManager/Cache.service/connectors/RAMCache.class'; export * from './subsystems/MemoryManager/Cache.service/connectors/RedisCache.class'; +export * from './subsystems/MemoryManager/Cache.service/connectors/MemcachedCache.class'; export * from './subsystems/MemoryManager/Cache.service/connectors/S3Cache.class'; export * from './subsystems/Security/Account.service/connectors/AWSAccount.class'; export * from './subsystems/Security/Account.service/connectors/DummyAccount.class'; diff --git a/packages/core/src/subsystems/MemoryManager/Cache.service/connectors/MemcachedCache.class.ts b/packages/core/src/subsystems/MemoryManager/Cache.service/connectors/MemcachedCache.class.ts new file mode 100644 index 00000000..1f9b6be0 --- /dev/null +++ b/packages/core/src/subsystems/MemoryManager/Cache.service/connectors/MemcachedCache.class.ts @@ -0,0 +1,214 @@ +import { Logger } from '@sre/helpers/Log.helper'; +import { IAccessCandidate, IACL, TAccessLevel } from '@sre/types/ACL.types'; +import { CacheMetadata } from '@sre/types/Cache.types'; +import { CacheConnector } from '../CacheConnector'; +import { ACL } from '@sre/Security/AccessControl/ACL.class'; +import { AccessRequest } from '@sre/Security/AccessControl/AccessRequest.class'; +import { SecureConnector } from '@sre/Security/SecureConnector.class'; + +import memjs, { Client } from 'memjs'; +import { MemcachedConfig } from '@sre/types/Memcached.types'; + + +const console = Logger('MemcachedCache'); + + +export class MemcachedCache extends CacheConnector { + public name: string = 'MemcachedCache'; + private memcached: Client; + private _prefix: string; + private _mdPrefix: string; + + constructor(protected _settings: MemcachedConfig) { + super(_settings); + + const hosts = Array.isArray(_settings.hosts) + ? _settings.hosts + .map((h) => (typeof h === 'string' ? h : `${h.host}:${h.port}`)) + .join(',') + : typeof _settings.hosts === 'string' + ? _settings.hosts + : ''; + + this.memcached = memjs.Client.create(hosts, { + username: _settings.username, + password: _settings.password, + }); + + this._prefix = _settings.prefix || 'smyth:cache'; + this._mdPrefix = _settings.metadataPrefix || 'smyth:metadata'; + + console.log(`Memcached connected: ${hosts}`); + } + + public get client() { + return this.memcached; + } + + public get prefix() { + return this._prefix; + } + + public get mdPrefix() { + return this._mdPrefix; + } + +@SecureConnector.AccessControl +public async get(acRequest: AccessRequest, key: string): Promise { + const cacheKey = `${this._prefix}:${key}`; + const mdKey = `${this._mdPrefix}:${key}`; + + const { value } = await this.memcached.get(cacheKey); + if (!value) return null; + + const { value: mdValue } = await this.memcached.get(mdKey); + let metadata: CacheMetadata | undefined; + if (mdValue) { + metadata = this.deserializeMetadata(mdValue.toString()); + } + + if (metadata?.expiresAt && metadata.expiresAt < Date.now()) { + await Promise.all([ + this.memcached.delete(cacheKey), + this.memcached.delete(mdKey), + ]); + return null; + } + + return value.toString(); +} + + @SecureConnector.AccessControl + public async set( + acRequest: AccessRequest, + key: string, + data: any, + acl?: IACL, + metadata?: CacheMetadata, + ttl?: number + ): Promise { + const accessCandidate = acRequest.candidate; + const newMetadata: CacheMetadata = metadata || {}; + newMetadata.acl = ACL.from(acl).addAccess( + accessCandidate.role, + accessCandidate.id, + TAccessLevel.Owner + ).ACL; + + if (ttl) { + newMetadata.expiresAt = Date.now() + ttl * 1000; + } + + await Promise.all([ + this.memcached.set(`${this._prefix}:${key}`, Buffer.from(String(data)), { expires: ttl || 0 }), + this.memcached.set(`${this._mdPrefix}:${key}`, Buffer.from(this.serializeMetadata(newMetadata)), { expires: ttl || 0 }), + ]); + + return true; + } + + @SecureConnector.AccessControl + public async delete(acRequest: AccessRequest, key: string): Promise { + await Promise.all([ + this.memcached.delete(`${this._prefix}:${key}`), + this.memcached.delete(`${this._mdPrefix}:${key}`), + ]); + } + + @SecureConnector.AccessControl + public async exists(acRequest: AccessRequest, key: string): Promise { + const { value } = await this.memcached.get(`${this._prefix}:${key}`); + return !!value; + } + + @SecureConnector.AccessControl + public async getMetadata(acRequest: AccessRequest, key: string): Promise { + const { value } = await this.memcached.get(`${this._mdPrefix}:${key}`); + if (!value) return undefined; + return this.deserializeMetadata(value.toString()); + } + + @SecureConnector.AccessControl + public async setMetadata(acRequest: AccessRequest, key: string, metadata: CacheMetadata): Promise { + await this.memcached.set(`${this._mdPrefix}:${key}`, Buffer.from(this.serializeMetadata(metadata))); + } + +public async updateTTL(acRequest: AccessRequest, key: string, ttl?: number): Promise { + if (!ttl) return; + + const namespacedKey = `${this._prefix}:${key}`; + const namespacedMdKey = `${this._mdPrefix}:${key}`; + + const { value } = await this.memcached.get(namespacedKey); + const { value: md } = await this.memcached.get(namespacedMdKey); + + if (value) { + await this.memcached.set(namespacedKey, value, { expires: ttl }); + } + + if (md) { + const metadata = JSON.parse(this.serializeMetadata(md)); + metadata.expiresAt = Date.now() + ttl * 1000; + await this.memcached.set(namespacedMdKey, JSON.stringify(metadata), { expires: ttl }); + } +} + + + @SecureConnector.AccessControl + public async getTTL(acRequest: AccessRequest, key: string): Promise { + // Memcached does not support direct TTL querying like Redis + // so we store expiresAt inside metadata + const metadata = await this.getMetadata(acRequest, key); + if (!metadata?.expiresAt) return -1; + return Math.max(0, Math.floor((metadata.expiresAt - Date.now()) / 1000)); + } + + public async getResourceACL(resourceId: string, candidate: IAccessCandidate): Promise { + const { value } = await this.memcached.get(`${this._mdPrefix}:${resourceId}`); + if (!value) { + return new ACL().addAccess(candidate.role, candidate.id, TAccessLevel.Owner); + } + const metadata = this.deserializeMetadata(value.toString()); + return ACL.from(metadata?.acl as IACL); + } + + @SecureConnector.AccessControl + async getACL(acRequest: AccessRequest, key: string): Promise { + const metadata = await this.getMetadata(acRequest, key); + return metadata?.acl as IACL; + } + + @SecureConnector.AccessControl + async setACL(acRequest: AccessRequest, key: string, acl: IACL) { + let metadata = (await this.getMetadata(acRequest, key)) || {}; + metadata.acl = ACL.from(acl).addAccess( + acRequest.candidate.role, + acRequest.candidate.id, + TAccessLevel.Owner + ).ACL; + await this.setMetadata(acRequest, key, metadata); + } + + private serializeMetadata(metadata: CacheMetadata): string { + const cloned = { ...metadata }; + if (cloned.acl) cloned.acl = ACL.from(cloned.acl).serializedACL; + if (metadata.expiresAt) cloned.expiresAt = metadata.expiresAt; + return JSON.stringify(cloned); + } + + private deserializeMetadata(str: string): CacheMetadata { + try { + const obj = JSON.parse(str); + if (obj.acl) obj.acl = ACL.from(obj.acl).ACL; + return obj; + } catch (e) { + console.warn('Error deserializing metadata', str); + return {}; + } + } + + public async stop() { + super.stop(); + await this.memcached.quit(); + } +} diff --git a/packages/core/src/subsystems/MemoryManager/Cache.service/index.ts b/packages/core/src/subsystems/MemoryManager/Cache.service/index.ts index 55c993ca..69bec03f 100644 --- a/packages/core/src/subsystems/MemoryManager/Cache.service/index.ts +++ b/packages/core/src/subsystems/MemoryManager/Cache.service/index.ts @@ -5,11 +5,13 @@ import { RedisCache } from './connectors/RedisCache.class'; import { S3Cache } from './connectors/S3Cache.class'; import { LocalStorageCache } from './connectors/LocalStorageCache.class'; import { RAMCache } from './connectors/RAMCache.class'; +import { MemcachedCache } from './connectors/MemcachedCache.class'; export class CacheService extends ConnectorServiceProvider { public register() { ConnectorService.register(TConnectorService.Cache, 'Redis', RedisCache); ConnectorService.register(TConnectorService.Cache, 'S3', S3Cache); ConnectorService.register(TConnectorService.Cache, 'LocalStorage', LocalStorageCache); ConnectorService.register(TConnectorService.Cache, 'RAM', RAMCache); + ConnectorService.register(TConnectorService.Cache, 'Memcached', MemcachedCache); } } diff --git a/packages/core/src/types/Memcached.types.ts b/packages/core/src/types/Memcached.types.ts new file mode 100644 index 00000000..fcc0a325 --- /dev/null +++ b/packages/core/src/types/Memcached.types.ts @@ -0,0 +1,14 @@ +//==[ SRE: Memcached Types ]====================== + + +export type MemcachedConfig = { + name: string; + hosts: string | string[] | { + host: string; + port: number; + }[]; + username?: string; + password?: string; + prefix?: string; + metadataPrefix?: string; +}; diff --git a/packages/core/tests/integration/connectors/Cache/Memcached.test.ts b/packages/core/tests/integration/connectors/Cache/Memcached.test.ts new file mode 100644 index 00000000..6dc19512 --- /dev/null +++ b/packages/core/tests/integration/connectors/Cache/Memcached.test.ts @@ -0,0 +1,88 @@ +import { MemcachedCache } from '@sre/MemoryManager/Cache.service/connectors/MemcachedCache.class'; +import { AccessRequest } from '@sre/Security/AccessControl/AccessRequest.class'; +import { TAccessLevel } from '@sre/types/ACL.types'; +import { describe, beforeAll, afterAll, it, expect } from 'vitest'; + +import { vi } from "vitest"; + +vi.mock("@sre/Security/SecureConnector.class", async () => { + const actual = await vi.importActual("@sre/Security/SecureConnector.class"); + + class MockSecureConnector extends actual.SecureConnector { + static AccessControl() { + return function ( + _target: any, + _propertyKey: string, + descriptor: PropertyDescriptor + ) { + return descriptor; + }; + } + } + + return { ...actual, SecureConnector: MockSecureConnector }; +}); + +describe('MemcachedCache (integration)', () => { + let cache: MemcachedCache; + let acRequest: AccessRequest; + + beforeAll(() => { + cache = new MemcachedCache({ + hosts: 'localhost:11211', + prefix: 'test:cache', + metadataPrefix: 'test:meta', + } as any); + + acRequest = new AccessRequest({ id: 'user1', role: 'user' } as any) + .resource("memcached:test"); ; + }); + + afterAll(async () => { + await cache.stop(); + }); + + it('should set and get a simple value', async () => { + await cache.set(acRequest, 'foo', 'bar', undefined, undefined, 5); + const val = await cache.get(acRequest, 'foo'); + expect(val).toBe('bar'); + }); + + it('should check if a key exists', async () => { + await cache.set(acRequest, 'existsKey', '123'); + const exists = await cache.exists(acRequest, 'existsKey'); + expect(exists).toBe(true); + }); + + it('should delete a key', async () => { + await cache.set(acRequest, 'toDelete', 'value'); + await cache.delete(acRequest, 'toDelete'); + const val = await cache.get(acRequest, 'toDelete'); + expect(val).toBeNull(); + }); + + it('should store and retrieve metadata', async () => { + const metadata = { custom: 'meta', expiresAt: Date.now() + 5000 }; + await cache.setMetadata(acRequest, 'metaKey', metadata); + + const result = await cache.getMetadata(acRequest, 'metaKey'); + expect(result).toMatchObject(metadata); + }); + + it('should update TTL', async () => { + await cache.set(acRequest, 'ttlKey', 'withTTL', undefined, undefined, 2); + let ttl = await cache.getTTL(acRequest, 'ttlKey'); + expect(ttl).toBeGreaterThan(0); + + await cache.updateTTL(acRequest, 'ttlKey', 10); + ttl = await cache.getTTL(acRequest, 'ttlKey'); + expect(ttl).toBeGreaterThanOrEqual(10); + }); + + it('should return expired key as null', async () => { + await cache.set(acRequest, 'expireKey', 'will-expire', undefined, undefined, 1); + await new Promise((res) => setTimeout(res, 1500)); + const val = await cache.get(acRequest, 'expireKey'); + expect(val).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97c01216..ea306f26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + memjs: + specifier: ^1.3.2 + version: 1.3.2 mime: specifier: ^4.0.3 version: 4.0.7 @@ -331,6 +334,9 @@ importers: '@types/lodash': specifier: ^4.17.10 version: 4.17.17 + '@types/memjs': + specifier: ^1.3.3 + version: 1.3.3 '@types/node': specifier: ^20.19.0 version: 20.19.0 @@ -370,6 +376,9 @@ importers: typescript: specifier: ^5.4.5 version: 5.8.3 + vitest: + specifier: ^3.2.3 + version: 3.2.3(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0) packages/sdk: dependencies: @@ -1975,6 +1984,9 @@ packages: '@types/lodash@4.17.17': resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/memjs@1.3.3': + resolution: {integrity: sha512-OqZuZ2T0ErrygA/WUqQjmsB1GpxAt84OCgKPKbsSWwYKA/oe/lTiqvqMgFOv/Ngl8p+nytgdhrdxJtikOgimCg==} + '@types/mime-types@3.0.1': resolution: {integrity: sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==} @@ -3739,6 +3751,10 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memjs@1.3.2: + resolution: {integrity: sha512-qUEg2g8vxPe+zPn09KidjIStHPtoBO8Cttm8bgJFWWabbsjQ9Av9Ky+6UcvKx6ue0LLb/LEhtcyQpRyKfzeXcg==} + engines: {node: '>=0.10.0'} + memoize@10.1.0: resolution: {integrity: sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==} engines: {node: '>=18'} @@ -7791,6 +7807,10 @@ snapshots: '@types/lodash@4.17.17': {} + '@types/memjs@1.3.3': + dependencies: + '@types/node': 20.19.0 + '@types/mime-types@3.0.1': {} '@types/mime@1.3.5': {} @@ -7888,6 +7908,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.3(vite@6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0))': + dependencies: + '@vitest/spy': 3.2.3 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0) + '@vitest/mocker@3.2.3(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.3 @@ -9805,6 +9833,8 @@ snapshots: media-typer@1.1.0: {} + memjs@1.3.2: {} + memoize@10.1.0: dependencies: mimic-function: 5.0.1 @@ -11092,6 +11122,27 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.3(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): + dependencies: + cac: 6.7.14 + debug: 4.4.1(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.3(@types/node@22.15.31)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): dependencies: cac: 6.7.14 @@ -11124,6 +11175,22 @@ snapshots: - supports-color - typescript + vite@6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): + dependencies: + esbuild: 0.25.5 + fdir: 6.4.5(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.4 + rollup: 4.42.0 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 20.19.0 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.41.0 + tsx: 4.19.4 + yaml: 2.8.0 + vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): dependencies: esbuild: 0.25.5 @@ -11140,6 +11207,47 @@ snapshots: tsx: 4.19.4 yaml: 2.8.0 + vitest@3.2.3(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.3 + '@vitest/mocker': 3.2.3(vite@6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0)) + '@vitest/pretty-format': 3.2.3 + '@vitest/runner': 3.2.3 + '@vitest/snapshot': 3.2.3 + '@vitest/spy': 3.2.3 + '@vitest/utils': 3.2.3 + chai: 5.2.0 + debug: 4.4.1(supports-color@8.1.1) + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.0 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0) + vite-node: 3.2.3(@types/node@20.19.0)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.3(@types/node@22.15.31)(jiti@2.4.2)(terser@5.41.0)(tsx@4.19.4)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2