adding freight web and holding

This commit is contained in:
JB
2025-12-24 00:38:53 -05:00
parent 0c8630b8ba
commit f39e9132ad
164 changed files with 5559 additions and 156655 deletions

View File

@@ -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);
});
});

View File

@@ -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()),
};

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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/*"]
}