Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ LICENSE
README.md
build.js
scripts/
src/cache.test.ts
src/cache.ts
src/cache/
src/core/fetcher/custom.ts
src/datastream/
src/events.test.ts
src/events.ts
src/index.ts
src/logger.ts
src/rules-engine.test.ts
src/rules-engine.ts
src/version.ts
src/wasm/
src/webhooks.ts
src/wrapper.ts
tests/unit/webhooks.test.ts
6 changes: 6 additions & 0 deletions build.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// build.js
const esbuild = require('esbuild');
const { execSync } = require('child_process');
const { copyFileSync } = require('fs');

const sharedConfig = {
entryPoints: ['src/index.ts'],
Expand Down Expand Up @@ -32,6 +33,11 @@ async function build() {

console.log('✅ JavaScript build completed with esbuild');

// Copy WASM files to dist
console.log('🔧 Copying WASM files...');
copyFileSync('src/wasm/rulesengine_bg.wasm', 'dist/rulesengine_bg.wasm');
console.log('✅ WASM files copied');

// Generate TypeScript declarations with tsc
console.log('🔧 Generating TypeScript declarations...');
execSync('tsc --emitDeclarationOnly --outDir dist', { stdio: 'inherit' });
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
"path": false,
"stream": false,
"crypto": false,
"timers": false
"timers": false,
"url": false,
"ws": false
},
"packageManager": "yarn@1.22.22",
"engines": {
Expand All @@ -61,6 +63,7 @@
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",
"dist/**/*.wasm",
"!dist/**/*.js.map",
"!dist/**/*.test.*",
"README.md"
Expand Down
8 changes: 8 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Cache interfaces and types
export { type CacheProvider, type CacheOptions } from "./types";

// Memory cache implementation
export { LocalCache } from "./local";

// Redis cache implementation (requires 'redis' package)
export { RedisCacheProvider, type RedisOptions } from "./redis";
12 changes: 4 additions & 8 deletions src/cache.test.ts → src/cache/local.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint @typescript-eslint/no-explicit-any: 0 */

import { LocalCache } from "./cache";
import { LocalCache } from "./local";

jest.useFakeTimers();

Expand All @@ -12,7 +12,6 @@ describe("LocalCache", () => {
});

afterEach(() => {
cache.resetCache();
jest.clearAllTimers();
});

Expand All @@ -26,7 +25,7 @@ describe("LocalCache", () => {
await cache.set("key1", { data: "value1" }, 1000); // TTL: 1 second
jest.advanceTimersByTime(1001); // Advance time by 1 second and 1 millisecond
const value = await cache.get("key1");
expect(value).toBeUndefined();
expect(value).toBeNull();
});

it("should evict least recently used item when maxItems is exceeded", async () => {
Expand All @@ -50,10 +49,9 @@ describe("LocalCache", () => {
const value4 = await smallCache.get("key4");

expect(value1).toEqual({ data: "value1" });
expect(value2).toBeUndefined(); // key2 should be evicted
expect(value2).toBeNull(); // key2 should be evicted
expect(value3).toEqual({ data: "value3" });
expect(value4).toEqual({ data: "value4" });
smallCache.resetCache();
});

it("should update the access counter on get", async () => {
Expand Down Expand Up @@ -83,8 +81,7 @@ describe("LocalCache", () => {
const zeroItemCache = new LocalCache({ maxItems: 0 });
await zeroItemCache.set("key1", { data: "value1" });
const value = await zeroItemCache.get("key1");
expect(value).toBeUndefined();
zeroItemCache.resetCache();
expect(value).toBeNull();
});

it("should maintain the correct number of items", async () => {
Expand All @@ -96,6 +93,5 @@ describe("LocalCache", () => {
}

expect((testCache as any).cache.size).toBe(maxItems);
testCache.resetCache();
});
});
44 changes: 23 additions & 21 deletions src/cache.ts → src/cache/local.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { setTimeout, clearTimeout } from "timers";

interface CacheProvider<T> {
get(key: string): Promise<T | undefined>;
set(key: string, value: T, ttlOverride?: number): Promise<void>;
}
import { CacheProvider, CacheOptions } from "./types";

type CacheItem<T> = {
value: T;
Expand All @@ -12,11 +8,9 @@ type CacheItem<T> = {
timeoutId?: ReturnType<typeof setTimeout>;
};

export interface CacheOptions {
maxItems?: number;
ttl?: number;
}

/**
* In-memory cache implementation with LRU eviction and TTL support
*/
class LocalCache<T> implements CacheProvider<T> {
private cache: Map<string, CacheItem<T>>;
private maxItems: number;
Expand All @@ -29,14 +23,14 @@ class LocalCache<T> implements CacheProvider<T> {
this.defaultTTL = ttl;
}

async get(key: string): Promise<T | undefined> {
async get(key: string): Promise<T | null> {
const item = this.cache.get(key);
if (!item) return undefined;
if (!item) return null;

// Check if the item has expired
if (item.expiration <= Date.now()) {
this.evictItem(key, item);
return undefined;
return null;
}

// Update the access counter for LRU eviction
Expand Down Expand Up @@ -71,19 +65,27 @@ class LocalCache<T> implements CacheProvider<T> {
value,
accessCounter: this.accessCounter,
expiration: Date.now() + ttl,
timeoutId: setTimeout(() => this.evictItem(key, newItem), ttl),
};

// Set timeout after item is created to avoid circular reference
newItem.timeoutId = setTimeout(() => this.evictItem(key, newItem), ttl);
this.cache.set(key, newItem);
}

resetCache(): void {
this.cache.forEach((item) => {
if (item.timeoutId) {
clearTimeout(item.timeoutId);
async delete(key: string): Promise<void> {
const item = this.cache.get(key);
if (item) {
this.evictItem(key, item);
}
}

async deleteMissing(keysToKeep: string[], _?: { scanPattern?: string }): Promise<void> {
const keysToKeepSet = new Set(keysToKeep);
for (const [key, item] of this.cache) {
if (!keysToKeepSet.has(key)) {
this.evictItem(key, item);
}
});
this.cache.clear();
this.accessCounter = 0;
}
}

private evictItem(key: string, item: CacheItem<T>): void {
Expand Down
188 changes: 188 additions & 0 deletions src/cache/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { CacheProvider, CacheOptions } from "./types";

export interface RedisOptions extends CacheOptions {
/** Redis connection URL (e.g., 'redis://localhost:6379') */
url?: string;
/** Redis host (default: 'localhost') */
host?: string;
/** Redis port (default: 6379) */
port?: number;
/** Redis password */
password?: string;
/** Redis database number (default: 0) */
db?: number;
/** Redis key prefix for all cache keys (default: 'schematic:') */
keyPrefix?: string;
}

/**
* Redis-based cache provider implementation
* Requires the 'redis' package to be installed: npm install redis
*/
export class RedisCacheProvider<T> implements CacheProvider<T> {
private client: any; // Redis client type
private defaultTTL: number;
private keyPrefix: string;
private isConnected: boolean = false;
private initPromise: Promise<void>;

constructor(options: RedisOptions = {}) {
this.defaultTTL = Math.floor((options.ttl || 5000) / 1000); // Convert to seconds for Redis
this.keyPrefix = options.keyPrefix || 'schematic:';

// Dynamically import Redis to avoid requiring it if not used
this.initPromise = this.initRedisClient(options);
}

private async initRedisClient(options: RedisOptions): Promise<void> {
try {
// Dynamically import redis so it's only loaded if actually used
const redisModule = await import('redis' as any);
const { createClient } = redisModule;

let clientConfig: any = {};

if (options.url) {
clientConfig.url = options.url;
} else {
clientConfig.socket = {
host: options.host || 'localhost',
port: options.port || 6379,
};

if (options.password) {
clientConfig.password = options.password;
}

if (options.db !== undefined) {
clientConfig.database = options.db;
}
}

this.client = createClient(clientConfig);

this.client.on('error', () => {
this.isConnected = false;
});

this.client.on('connect', () => {
this.isConnected = true;
});

this.client.on('disconnect', () => {
this.isConnected = false;
});

await this.client.connect();

} catch (error) {
throw new Error(
'Redis package not found. Please install it with: npm install redis\n' +
'Original error: ' + (error as Error).message
);
}
}

private getFullKey(key: string): string {
return this.keyPrefix + key;
}

async get(key: string): Promise<T | null> {
await this.initPromise;

if (!this.isConnected || !this.client) {
return null;
}

const fullKey = this.getFullKey(key);
const value = await this.client.get(fullKey);

if (value === null) {
return null;
}

return JSON.parse(value) as T;
}

async set(key: string, value: T, ttl?: number): Promise<void> {
await this.initPromise;

if (!this.isConnected || !this.client) {
return;
}

const fullKey = this.getFullKey(key);
const serializedValue = JSON.stringify(value);
const actualTTL = ttl ? Math.floor(ttl / 1000) : this.defaultTTL;

if (actualTTL > 0) {
await this.client.setEx(fullKey, actualTTL, serializedValue);
} else {
await this.client.set(fullKey, serializedValue);
}
}

async delete(key: string): Promise<void> {
await this.initPromise;

if (!this.isConnected || !this.client) {
return;
}

const fullKey = this.getFullKey(key);
await this.client.del(fullKey);
}

async deleteMissing(keysToKeep: string[], options?: { scanPattern?: string }): Promise<void> {
await this.initPromise;

if (!this.isConnected || !this.client) {
return;
}

// Get all keys with our prefix using SCAN (non-blocking)
// Allow more specific pattern to reduce keys scanned (e.g., 'flag:*' to only scan flag keys)
const pattern = options?.scanPattern
? this.keyPrefix + options.scanPattern
: this.keyPrefix + '*';
const fullKeysToKeep = new Set(keysToKeep.map(k => this.getFullKey(k)));
const keysToDelete: string[] = [];
const batchSize = 1000;

for await (const key of this.client.scanIterator({ MATCH: pattern, COUNT: 1000 })) {
if (!fullKeysToKeep.has(key)) {
keysToDelete.push(key);

// Delete in batches to avoid memory buildup with millions of keys
if (keysToDelete.length >= batchSize) {
await this.client.del(keysToDelete);
keysToDelete.length = 0; // Clear array
}
}
}

// Delete remaining keys
if (keysToDelete.length > 0) {
await this.client.del(keysToDelete);
}
}

/**
* Close the Redis connection
*/
async close(): Promise<void> {
await this.initPromise;

if (this.client) {
await this.client.quit();
this.isConnected = false;
}
}

/**
* Check if the Redis client is connected
*/
isReady(): boolean {
return this.isConnected;
}
}
Loading