adding freight web and holding
This commit is contained in:
@@ -1,188 +1,188 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { connectDB, get, set, del, has, clear, getDB, asyncKV } from './kv';
|
||||
|
||||
beforeEach(() => {
|
||||
// Use in-memory database for tests
|
||||
connectDB(':memory:');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear the database after each test
|
||||
clear();
|
||||
});
|
||||
|
||||
describe('KV Store Tests', () => {
|
||||
test('set and get string value', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('testValue');
|
||||
});
|
||||
|
||||
test('set and get object value', async () => {
|
||||
const obj = { name: 'test', value: 42 };
|
||||
await set('testKey', obj);
|
||||
const value = await get<typeof obj>('testKey');
|
||||
expect(value).toEqual(obj);
|
||||
});
|
||||
|
||||
test('set and get boolean value', async () => {
|
||||
await set('testKey', true);
|
||||
const value = await get<boolean>('testKey');
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
|
||||
test('set and get null value', async () => {
|
||||
await set('testKey', null);
|
||||
const value = await get<null>('testKey');
|
||||
expect(value).toBe(null);
|
||||
});
|
||||
|
||||
test('get non-existent key returns undefined', async () => {
|
||||
const value = await get('nonExistent');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('set with TTL and get before expiration', async () => {
|
||||
await set('testKey', 'testValue', 1); // 1 second TTL
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('testValue');
|
||||
});
|
||||
|
||||
test('set with TTL and get after expiration', async () => {
|
||||
await set('testKey', 'testValue', 0.001); // Very short TTL
|
||||
await new Promise((resolve) => setTimeout(resolve, 10)); // Wait for expiration
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('del existing key', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const deleted = await del('testKey');
|
||||
expect(deleted).toBe(1); // Number of rows deleted
|
||||
const value = await get('testKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('del non-existent key', async () => {
|
||||
const deleted = await del('nonExistent');
|
||||
expect(deleted).toBe(0); // del returns 0 for non-existent keys
|
||||
});
|
||||
|
||||
test('del multiple keys', async () => {
|
||||
await set('key1', 'value1');
|
||||
await set('key2', 'value2');
|
||||
await set('key3', 'value3');
|
||||
const deleted = await del(['key1', 'key2', 'nonExistent']);
|
||||
expect(deleted).toBe(2); // key1 and key2 deleted, nonExistent not
|
||||
expect(await get('key1')).toBeUndefined();
|
||||
expect(await get('key2')).toBeUndefined();
|
||||
expect(await get('key3')).toBeDefined(); // key3 still exists
|
||||
});
|
||||
|
||||
test('has existing key', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const exists = await has('testKey');
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
test('has non-existent key', async () => {
|
||||
const exists = await has('nonExistent');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('has key with expired TTL', async () => {
|
||||
await set('testKey', 'testValue', 0.001);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const exists = await has('testKey');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('clear all keys', async () => {
|
||||
await set('key1', 'value1');
|
||||
await set('key2', 'value2');
|
||||
await clear();
|
||||
const value1 = await get('key1');
|
||||
const value2 = await get('key2');
|
||||
expect(value1).toBeUndefined();
|
||||
expect(value2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getDB returns database instance', () => {
|
||||
const db = getDB();
|
||||
expect(db).toBeDefined();
|
||||
expect(db.constructor.name).toBe('Database');
|
||||
});
|
||||
|
||||
test('connectDB with custom path', () => {
|
||||
connectDB(':memory:'); // Already done in beforeEach, but testing again
|
||||
const db = getDB();
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test('set and get zero value', async () => {
|
||||
await set('testKey', 0);
|
||||
const value = await get<number>('testKey');
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
test('set and get empty string', async () => {
|
||||
await set('testKey', '');
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('');
|
||||
});
|
||||
|
||||
test('set with false value', async () => {
|
||||
await set('testKey', false);
|
||||
const value = await get<boolean>('testKey');
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
test('JSON parsing error falls back to string', async () => {
|
||||
// Manually insert invalid JSON to test fallback
|
||||
const db = getDB();
|
||||
db.run(`INSERT OR REPLACE INTO kvstore VALUES (?, ?, ?, ?)`, ['testKey', 'invalid json', null, new Date().toISOString()]);
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('invalid json');
|
||||
});
|
||||
|
||||
// Tests for asyncKV wrapper functions
|
||||
test('asyncKV set and get', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const value = await asyncKV.get<string>('asyncKey');
|
||||
expect(value).toBe('asyncValue');
|
||||
});
|
||||
|
||||
test('asyncKV del', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const deleted = await asyncKV.del('asyncKey');
|
||||
expect(deleted).toBe(1);
|
||||
const value = await asyncKV.get('asyncKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('asyncKV has', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const exists = await asyncKV.has('asyncKey');
|
||||
expect(exists).toBe(true);
|
||||
await asyncKV.del('asyncKey');
|
||||
const existsAfter = await asyncKV.has('asyncKey');
|
||||
expect(existsAfter).toBe(false);
|
||||
});
|
||||
|
||||
test('asyncKV clear', async () => {
|
||||
await asyncKV.set('key1', 'value1');
|
||||
await asyncKV.set('key2', 'value2');
|
||||
await asyncKV.clear();
|
||||
const value1 = await asyncKV.get('key1');
|
||||
const value2 = await asyncKV.get('key2');
|
||||
expect(value1).toBeUndefined();
|
||||
expect(value2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('asyncKV del multiple keys', async () => {
|
||||
await asyncKV.set('key1', 'value1');
|
||||
await asyncKV.set('key2', 'value2');
|
||||
const deleted = await asyncKV.del(['key1', 'key2']);
|
||||
expect(deleted).toBe(2);
|
||||
});
|
||||
});
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { connectDB, get, set, del, has, clear, getDB, asyncKV } from './kv';
|
||||
|
||||
beforeEach(() => {
|
||||
// Use in-memory database for tests
|
||||
connectDB(':memory:');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear the database after each test
|
||||
clear();
|
||||
});
|
||||
|
||||
describe('KV Store Tests', () => {
|
||||
test('set and get string value', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('testValue');
|
||||
});
|
||||
|
||||
test('set and get object value', async () => {
|
||||
const obj = { name: 'test', value: 42 };
|
||||
await set('testKey', obj);
|
||||
const value = await get<typeof obj>('testKey');
|
||||
expect(value).toEqual(obj);
|
||||
});
|
||||
|
||||
test('set and get boolean value', async () => {
|
||||
await set('testKey', true);
|
||||
const value = await get<boolean>('testKey');
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
|
||||
test('set and get null value', async () => {
|
||||
await set('testKey', null);
|
||||
const value = await get<null>('testKey');
|
||||
expect(value).toBe(null);
|
||||
});
|
||||
|
||||
test('get non-existent key returns undefined', async () => {
|
||||
const value = await get('nonExistent');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('set with TTL and get before expiration', async () => {
|
||||
await set('testKey', 'testValue', 1); // 1 second TTL
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('testValue');
|
||||
});
|
||||
|
||||
test('set with TTL and get after expiration', async () => {
|
||||
await set('testKey', 'testValue', 0.001); // Very short TTL
|
||||
await new Promise((resolve) => setTimeout(resolve, 10)); // Wait for expiration
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('del existing key', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const deleted = await del('testKey');
|
||||
expect(deleted).toBe(1); // Number of rows deleted
|
||||
const value = await get('testKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('del non-existent key', async () => {
|
||||
const deleted = await del('nonExistent');
|
||||
expect(deleted).toBe(0); // del returns 0 for non-existent keys
|
||||
});
|
||||
|
||||
test('del multiple keys', async () => {
|
||||
await set('key1', 'value1');
|
||||
await set('key2', 'value2');
|
||||
await set('key3', 'value3');
|
||||
const deleted = await del(['key1', 'key2', 'nonExistent']);
|
||||
expect(deleted).toBe(2); // key1 and key2 deleted, nonExistent not
|
||||
expect(await get('key1')).toBeUndefined();
|
||||
expect(await get('key2')).toBeUndefined();
|
||||
expect(await get('key3')).toBeDefined(); // key3 still exists
|
||||
});
|
||||
|
||||
test('has existing key', async () => {
|
||||
await set('testKey', 'testValue');
|
||||
const exists = await has('testKey');
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
test('has non-existent key', async () => {
|
||||
const exists = await has('nonExistent');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('has key with expired TTL', async () => {
|
||||
await set('testKey', 'testValue', 0.001);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const exists = await has('testKey');
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('clear all keys', async () => {
|
||||
await set('key1', 'value1');
|
||||
await set('key2', 'value2');
|
||||
await clear();
|
||||
const value1 = await get('key1');
|
||||
const value2 = await get('key2');
|
||||
expect(value1).toBeUndefined();
|
||||
expect(value2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getDB returns database instance', () => {
|
||||
const db = getDB();
|
||||
expect(db).toBeDefined();
|
||||
expect(db.constructor.name).toBe('Database');
|
||||
});
|
||||
|
||||
test('connectDB with custom path', () => {
|
||||
connectDB(':memory:'); // Already done in beforeEach, but testing again
|
||||
const db = getDB();
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
test('set and get zero value', async () => {
|
||||
await set('testKey', 0);
|
||||
const value = await get<number>('testKey');
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
test('set and get empty string', async () => {
|
||||
await set('testKey', '');
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('');
|
||||
});
|
||||
|
||||
test('set with false value', async () => {
|
||||
await set('testKey', false);
|
||||
const value = await get<boolean>('testKey');
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
test('JSON parsing error falls back to string', async () => {
|
||||
// Manually insert invalid JSON to test fallback
|
||||
const db = getDB();
|
||||
db.run(`INSERT OR REPLACE INTO kvstore VALUES (?, ?, ?, ?)`, ['testKey', 'invalid json', null, new Date().toISOString()]);
|
||||
const value = await get<string>('testKey');
|
||||
expect(value).toBe('invalid json');
|
||||
});
|
||||
|
||||
// Tests for asyncKV wrapper functions
|
||||
test('asyncKV set and get', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const value = await asyncKV.get<string>('asyncKey');
|
||||
expect(value).toBe('asyncValue');
|
||||
});
|
||||
|
||||
test('asyncKV del', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const deleted = await asyncKV.del('asyncKey');
|
||||
expect(deleted).toBe(1);
|
||||
const value = await asyncKV.get('asyncKey');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test('asyncKV has', async () => {
|
||||
await asyncKV.set('asyncKey', 'asyncValue');
|
||||
const exists = await asyncKV.has('asyncKey');
|
||||
expect(exists).toBe(true);
|
||||
await asyncKV.del('asyncKey');
|
||||
const existsAfter = await asyncKV.has('asyncKey');
|
||||
expect(existsAfter).toBe(false);
|
||||
});
|
||||
|
||||
test('asyncKV clear', async () => {
|
||||
await asyncKV.set('key1', 'value1');
|
||||
await asyncKV.set('key2', 'value2');
|
||||
await asyncKV.clear();
|
||||
const value1 = await asyncKV.get('key1');
|
||||
const value2 = await asyncKV.get('key2');
|
||||
expect(value1).toBeUndefined();
|
||||
expect(value2).toBeUndefined();
|
||||
});
|
||||
|
||||
test('asyncKV del multiple keys', async () => {
|
||||
await asyncKV.set('key1', 'value1');
|
||||
await asyncKV.set('key2', 'value2');
|
||||
const deleted = await asyncKV.del(['key1', 'key2']);
|
||||
expect(deleted).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
/**
|
||||
* A simple key-value store using Bun's SQLite support.
|
||||
* Supports string, object, boolean, and null values.
|
||||
* Values can have an optional TTL (time-to-live) in seconds.
|
||||
*
|
||||
* Exports a set of async functions for easy replacement
|
||||
* with other storage solutions in the future.
|
||||
*
|
||||
* Usage:
|
||||
* import { get, set, del, has, clear, connectDB } from './kv';
|
||||
*
|
||||
* await connectDB(); // Optional: specify a custom database file path
|
||||
* await set('myKey', 'myValue', 60); // Set a key with a value and TTL
|
||||
* const value = await get('myKey'); // Get the value by key
|
||||
* await del('myKey'); // Delete the key
|
||||
* const exists = await has('myKey'); // Check if the key exists
|
||||
* await clear(); // Clear all keys
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
interface KVItem {
|
||||
key: string;
|
||||
value: string;
|
||||
ttl: number | null; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
export function connectDB(dbPath: string = process.env.STAR_KITTEN_KV_DB_PATH || ':memory:') {
|
||||
db = new Database(dbPath);
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS kvstore (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
ttl INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(key)
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDB() {
|
||||
if (!db) {
|
||||
connectDB();
|
||||
}
|
||||
return db!;
|
||||
}
|
||||
|
||||
export function get<T>(key: string): T | undefined {
|
||||
const db = getDB();
|
||||
const query = db.prepare(`SELECT value, ttl FROM kvstore WHERE key = ?`);
|
||||
const row = query.get(key) as KVItem | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
if (row.ttl && Date.now() > row.ttl) {
|
||||
del(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch (error) {
|
||||
return row.value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
export function del(key: string | string[]): number {
|
||||
try {
|
||||
if (typeof key === 'string') {
|
||||
const result = getDB().run(`DELETE FROM kvstore WHERE key = ?`, [key]);
|
||||
return result.changes;
|
||||
} else {
|
||||
const keys = key.map(() => '?').join(', ');
|
||||
const query = `DELETE FROM kvstore WHERE key IN (${keys})`;
|
||||
const result = getDB().run(query, key);
|
||||
return result.changes;
|
||||
}
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function set<T>(key: string, value: T, ttlInSeconds: number | null = null): boolean {
|
||||
const ttl = ttlInSeconds ? Date.now() + ttlInSeconds * 1000 : null;
|
||||
try {
|
||||
const stmt = getDB().run(`INSERT OR REPLACE INTO kvstore VALUES (?, ?, ?, ?)`, [
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
ttl,
|
||||
new Date().toISOString(),
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function has(key: string): boolean {
|
||||
const db = getDB();
|
||||
const query = db.prepare(`SELECT ttl FROM kvstore WHERE key = ?`);
|
||||
const row = query.get(key) as { ttl?: number } | undefined;
|
||||
if (!row) return false;
|
||||
if (row.ttl && Date.now() > row.ttl) {
|
||||
del(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function purgeOlderThan(ageInSeconds: number): number {
|
||||
const cutoff = Date.now() - ageInSeconds * 1000;
|
||||
const result = getDB().run(`DELETE FROM kvstore WHERE created_at < ?`, [new Date(cutoff).toISOString()]);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
getDB().run(`DELETE FROM kvstore`);
|
||||
}
|
||||
|
||||
export default {
|
||||
get,
|
||||
set,
|
||||
delete: del,
|
||||
del,
|
||||
has,
|
||||
clear,
|
||||
};
|
||||
|
||||
export const asyncKV = {
|
||||
get: <T>(key: string): Promise<T | undefined> => Promise.resolve(get<T>(key)),
|
||||
set: <T>(key: string, value: T, ttlInSeconds: number | null = null): Promise<boolean> =>
|
||||
Promise.resolve(set<T>(key, value, ttlInSeconds)),
|
||||
delete: (key: string | string[]): Promise<number> => Promise.resolve(del(key)),
|
||||
del: (key: string | string[]): Promise<number> => Promise.resolve(del(key)),
|
||||
has: (key: string): Promise<boolean> => Promise.resolve(has(key)),
|
||||
clear: (): Promise<void> => Promise.resolve(clear()),
|
||||
};
|
||||
/**
|
||||
* A simple key-value store using Bun's SQLite support.
|
||||
* Supports string, object, boolean, and null values.
|
||||
* Values can have an optional TTL (time-to-live) in seconds.
|
||||
*
|
||||
* Exports a set of async functions for easy replacement
|
||||
* with other storage solutions in the future.
|
||||
*
|
||||
* Usage:
|
||||
* import { get, set, del, has, clear, connectDB } from './kv';
|
||||
*
|
||||
* await connectDB(); // Optional: specify a custom database file path
|
||||
* await set('myKey', 'myValue', 60); // Set a key with a value and TTL
|
||||
* const value = await get('myKey'); // Get the value by key
|
||||
* await del('myKey'); // Delete the key
|
||||
* const exists = await has('myKey'); // Check if the key exists
|
||||
* await clear(); // Clear all keys
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
interface KVItem {
|
||||
key: string;
|
||||
value: string;
|
||||
ttl: number | null; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
export function connectDB(dbPath: string = process.env.STAR_KITTEN_KV_DB_PATH || ':memory:') {
|
||||
db = new Database(dbPath);
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS kvstore (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
ttl INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(key)
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDB() {
|
||||
if (!db) {
|
||||
connectDB();
|
||||
}
|
||||
return db!;
|
||||
}
|
||||
|
||||
export function get<T>(key: string): T | undefined {
|
||||
const db = getDB();
|
||||
const query = db.prepare(`SELECT value, ttl FROM kvstore WHERE key = ?`);
|
||||
const row = query.get(key) as KVItem | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
if (row.ttl && Date.now() > row.ttl) {
|
||||
del(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch (error) {
|
||||
return row.value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
export function del(key: string | string[]): number {
|
||||
try {
|
||||
if (typeof key === 'string') {
|
||||
const result = getDB().run(`DELETE FROM kvstore WHERE key = ?`, [key]);
|
||||
return result.changes;
|
||||
} else {
|
||||
const keys = key.map(() => '?').join(', ');
|
||||
const query = `DELETE FROM kvstore WHERE key IN (${keys})`;
|
||||
const result = getDB().run(query, key);
|
||||
return result.changes;
|
||||
}
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function set<T>(key: string, value: T, ttlInSeconds: number | null = null): boolean {
|
||||
const ttl = ttlInSeconds ? Date.now() + ttlInSeconds * 1000 : null;
|
||||
try {
|
||||
const stmt = getDB().run(`INSERT OR REPLACE INTO kvstore VALUES (?, ?, ?, ?)`, [
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
ttl,
|
||||
new Date().toISOString(),
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function has(key: string): boolean {
|
||||
const db = getDB();
|
||||
const query = db.prepare(`SELECT ttl FROM kvstore WHERE key = ?`);
|
||||
const row = query.get(key) as { ttl?: number } | undefined;
|
||||
if (!row) return false;
|
||||
if (row.ttl && Date.now() > row.ttl) {
|
||||
del(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function purgeOlderThan(ageInSeconds: number): number {
|
||||
const cutoff = Date.now() - ageInSeconds * 1000;
|
||||
const result = getDB().run(`DELETE FROM kvstore WHERE created_at < ?`, [new Date(cutoff).toISOString()]);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
getDB().run(`DELETE FROM kvstore`);
|
||||
}
|
||||
|
||||
export default {
|
||||
get,
|
||||
set,
|
||||
delete: del,
|
||||
del,
|
||||
has,
|
||||
clear,
|
||||
};
|
||||
|
||||
export const asyncKV = {
|
||||
get: <T>(key: string): Promise<T | undefined> => Promise.resolve(get<T>(key)),
|
||||
set: <T>(key: string, value: T, ttlInSeconds: number | null = null): Promise<boolean> =>
|
||||
Promise.resolve(set<T>(key, value, ttlInSeconds)),
|
||||
delete: (key: string | string[]): Promise<number> => Promise.resolve(del(key)),
|
||||
del: (key: string | string[]): Promise<number> => Promise.resolve(del(key)),
|
||||
has: (key: string): Promise<boolean> => Promise.resolve(has(key)),
|
||||
clear: (): Promise<void> => Promise.resolve(clear()),
|
||||
};
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { createReactiveState } from './reactive-state';
|
||||
|
||||
describe('createReactiveState', () => {
|
||||
it('should call callback on property change with new and old state', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = false;
|
||||
let newState: any, oldState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called = true;
|
||||
newState = n;
|
||||
oldState = o;
|
||||
});
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(true);
|
||||
expect(newState.count).toBe(1);
|
||||
expect(oldState.count).toBe(0);
|
||||
});
|
||||
|
||||
it('should maintain reactivity when assigned to another variable', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = false;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called = true;
|
||||
});
|
||||
const newState = state;
|
||||
newState.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(true);
|
||||
expect(newState.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should be reactive when new properties are added', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called++;
|
||||
});
|
||||
state.newproperty = 'hello';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
state.secondProperty = 'world';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(2);
|
||||
expect(state.count).toBe(0);
|
||||
expect(state.newproperty).toBe('hello');
|
||||
expect(state.secondProperty).toBe('world');
|
||||
});
|
||||
|
||||
it('should batch multiple changes into one callback call', async () => {
|
||||
const initial = { a: 1, b: 2 };
|
||||
let callCount = 0;
|
||||
let finalNewState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n) => {
|
||||
callCount++;
|
||||
finalNewState = n;
|
||||
});
|
||||
state.a = 10;
|
||||
state.b = 20;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1);
|
||||
expect(finalNewState.a).toBe(10);
|
||||
expect(finalNewState.b).toBe(20);
|
||||
});
|
||||
|
||||
it('should support nested object changes', async () => {
|
||||
const initial = { nested: { value: 1 } };
|
||||
let called = false;
|
||||
let newState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n) => {
|
||||
called = true;
|
||||
newState = n;
|
||||
});
|
||||
state.nested.value = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(called).toBe(true);
|
||||
expect(newState.nested.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow unsubscribing from callbacks', async () => {
|
||||
const initial = { count: 0 };
|
||||
let callCount = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
const unsubscribe = subscribe(() => {
|
||||
callCount++;
|
||||
});
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1);
|
||||
unsubscribe();
|
||||
state.count = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1); // should not increase
|
||||
});
|
||||
|
||||
it('should support multiple subscribers', async () => {
|
||||
const initial = { count: 0 };
|
||||
let callCount1 = 0,
|
||||
callCount2 = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
const unsubscribe1 = subscribe(() => callCount1++);
|
||||
const unsubscribe2 = subscribe(() => callCount2++);
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount1).toBe(1);
|
||||
expect(callCount2).toBe(1);
|
||||
unsubscribe1();
|
||||
state.count = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount1).toBe(1);
|
||||
expect(callCount2).toBe(2);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { createReactiveState } from './reactive-state';
|
||||
|
||||
describe('createReactiveState', () => {
|
||||
it('should call callback on property change with new and old state', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = false;
|
||||
let newState: any, oldState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called = true;
|
||||
newState = n;
|
||||
oldState = o;
|
||||
});
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(true);
|
||||
expect(newState.count).toBe(1);
|
||||
expect(oldState.count).toBe(0);
|
||||
});
|
||||
|
||||
it('should maintain reactivity when assigned to another variable', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = false;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called = true;
|
||||
});
|
||||
const newState = state;
|
||||
newState.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(true);
|
||||
expect(newState.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should be reactive when new properties are added', async () => {
|
||||
const initial = { count: 0 };
|
||||
let called = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n, o) => {
|
||||
called++;
|
||||
});
|
||||
state.newproperty = 'hello';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
state.secondProperty = 'world';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for microtask
|
||||
expect(called).toBe(2);
|
||||
expect(state.count).toBe(0);
|
||||
expect(state.newproperty).toBe('hello');
|
||||
expect(state.secondProperty).toBe('world');
|
||||
});
|
||||
|
||||
it('should batch multiple changes into one callback call', async () => {
|
||||
const initial = { a: 1, b: 2 };
|
||||
let callCount = 0;
|
||||
let finalNewState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n) => {
|
||||
callCount++;
|
||||
finalNewState = n;
|
||||
});
|
||||
state.a = 10;
|
||||
state.b = 20;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1);
|
||||
expect(finalNewState.a).toBe(10);
|
||||
expect(finalNewState.b).toBe(20);
|
||||
});
|
||||
|
||||
it('should support nested object changes', async () => {
|
||||
const initial = { nested: { value: 1 } };
|
||||
let called = false;
|
||||
let newState: any;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
subscribe((n) => {
|
||||
called = true;
|
||||
newState = n;
|
||||
});
|
||||
state.nested.value = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(called).toBe(true);
|
||||
expect(newState.nested.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow unsubscribing from callbacks', async () => {
|
||||
const initial = { count: 0 };
|
||||
let callCount = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
const unsubscribe = subscribe(() => {
|
||||
callCount++;
|
||||
});
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1);
|
||||
unsubscribe();
|
||||
state.count = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount).toBe(1); // should not increase
|
||||
});
|
||||
|
||||
it('should support multiple subscribers', async () => {
|
||||
const initial = { count: 0 };
|
||||
let callCount1 = 0,
|
||||
callCount2 = 0;
|
||||
const [state, subscribe] = createReactiveState(initial);
|
||||
const unsubscribe1 = subscribe(() => callCount1++);
|
||||
const unsubscribe2 = subscribe(() => callCount2++);
|
||||
state.count = 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount1).toBe(1);
|
||||
expect(callCount2).toBe(1);
|
||||
unsubscribe1();
|
||||
state.count = 2;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(callCount1).toBe(1);
|
||||
expect(callCount2).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
/**
|
||||
* Creates a reactive state object that batches changes and notifies all subscribers with the full old and new state.
|
||||
*
|
||||
* @param initialState - The initial state object
|
||||
* @returns Object with { state: reactive proxy, subscribe: function to add callbacks }
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { state, subscribe } = createBatchedReactiveState({ count: 0, user: { name: 'Alice' } });
|
||||
*
|
||||
* const unsubscribe1 = subscribe((newState, oldState) => {
|
||||
* console.log('Subscriber 1:', oldState, '->', newState);
|
||||
* });
|
||||
*
|
||||
* const unsubscribe2 = subscribe((newState, oldState) => {
|
||||
* console.log('Subscriber 2:', newState.count);
|
||||
* });
|
||||
*
|
||||
* state.count = 1; // Triggers both subscribers once
|
||||
* state.user.name = 'Bob'; // Triggers both subscribers once (batched)
|
||||
*
|
||||
* unsubscribe1(); // Remove first subscriber
|
||||
* state.count = 2; // Only subscriber 2 is called
|
||||
* ```
|
||||
*/
|
||||
export function createReactiveState<T extends object>(initialState: T) {
|
||||
const callbacks = new Set<(newState: T, oldState: T) => void>();
|
||||
let isBatching = false;
|
||||
let oldState: T | null = null;
|
||||
let scheduled = false;
|
||||
|
||||
const rootState = cloneDeep(initialState);
|
||||
|
||||
function createReactiveObject(obj: any): any {
|
||||
return new Proxy(obj, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
return typeof value === 'object' && value !== null ? createReactiveObject(value) : value;
|
||||
},
|
||||
set(target, property, value, receiver) {
|
||||
if (!isBatching) {
|
||||
isBatching = true;
|
||||
oldState = cloneDeep(rootState);
|
||||
}
|
||||
const success = Reflect.set(target, property, value, receiver);
|
||||
if (success) {
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
callbacks.forEach((cb) => cb(rootState, oldState!));
|
||||
isBatching = false;
|
||||
oldState = null;
|
||||
scheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return success;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const state = createReactiveObject(rootState);
|
||||
|
||||
return [
|
||||
state,
|
||||
function subscribe(callback: (newState: T, oldState: T) => void) {
|
||||
callbacks.add(callback);
|
||||
return () => callbacks.delete(callback);
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
/**
|
||||
* Creates a reactive state object that batches changes and notifies all subscribers with the full old and new state.
|
||||
*
|
||||
* @param initialState - The initial state object
|
||||
* @returns Object with { state: reactive proxy, subscribe: function to add callbacks }
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { state, subscribe } = createBatchedReactiveState({ count: 0, user: { name: 'Alice' } });
|
||||
*
|
||||
* const unsubscribe1 = subscribe((newState, oldState) => {
|
||||
* console.log('Subscriber 1:', oldState, '->', newState);
|
||||
* });
|
||||
*
|
||||
* const unsubscribe2 = subscribe((newState, oldState) => {
|
||||
* console.log('Subscriber 2:', newState.count);
|
||||
* });
|
||||
*
|
||||
* state.count = 1; // Triggers both subscribers once
|
||||
* state.user.name = 'Bob'; // Triggers both subscribers once (batched)
|
||||
*
|
||||
* unsubscribe1(); // Remove first subscriber
|
||||
* state.count = 2; // Only subscriber 2 is called
|
||||
* ```
|
||||
*/
|
||||
export function createReactiveState<T extends object>(initialState: T) {
|
||||
const callbacks = new Set<(newState: T, oldState: T) => void>();
|
||||
let isBatching = false;
|
||||
let oldState: T | null = null;
|
||||
let scheduled = false;
|
||||
|
||||
const rootState = cloneDeep(initialState);
|
||||
|
||||
function createReactiveObject(obj: any): any {
|
||||
return new Proxy(obj, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
return typeof value === 'object' && value !== null ? createReactiveObject(value) : value;
|
||||
},
|
||||
set(target, property, value, receiver) {
|
||||
if (!isBatching) {
|
||||
isBatching = true;
|
||||
oldState = cloneDeep(rootState);
|
||||
}
|
||||
const success = Reflect.set(target, property, value, receiver);
|
||||
if (success) {
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
callbacks.forEach((cb) => cb(rootState, oldState!));
|
||||
isBatching = false;
|
||||
oldState = null;
|
||||
scheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return success;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const state = createReactiveObject(rootState);
|
||||
|
||||
return [
|
||||
state,
|
||||
function subscribe(callback: (newState: T, oldState: T) => void) {
|
||||
callbacks.add(callback);
|
||||
return () => callbacks.delete(callback);
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@star-kitten/discord",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user