bff-store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +45 -0
- package/CONTEXT.md +53 -0
- package/README.md +223 -0
- package/dist/cli.js +32577 -0
- package/dist/index.d.mts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.mjs +430 -0
- package/dist/package.json +62 -0
- package/dist/server/entry.d.mts +94 -0
- package/dist/server/entry.d.ts +94 -0
- package/dist/server/entry.js +573 -0
- package/dist/server/entry.mjs +533 -0
- package/dist/server-V7WCW4ZB.mjs +530 -0
- package/dist/storage/jsonl-entry.d.mts +42 -0
- package/dist/storage/jsonl-entry.d.ts +42 -0
- package/dist/storage/jsonl-entry.js +112 -0
- package/dist/storage/jsonl-entry.mjs +74 -0
- package/dist/storage/mongodb-entry.d.mts +40 -0
- package/dist/storage/mongodb-entry.d.ts +40 -0
- package/dist/storage/mongodb-entry.js +114 -0
- package/dist/storage/mongodb-entry.mjs +86 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
- package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
- package/docs/IMPLEMENTATION.md +333 -0
- package/docs/PLAN.md +153 -0
- package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
- package/docs/SIDECAR_SERVER.md +184 -0
- package/package.json +62 -0
- package/scripts/adapt-dist-package.js +33 -0
- package/src/atomCreator.ts +76 -0
- package/src/createStore.ts +77 -0
- package/src/debouncer.ts +84 -0
- package/src/index.ts +35 -0
- package/src/server/cli.ts +62 -0
- package/src/server/entityIdCache.ts +57 -0
- package/src/server/entry.ts +12 -0
- package/src/server/handlers.ts +271 -0
- package/src/server/index.ts +182 -0
- package/src/server/router.ts +74 -0
- package/src/server.ts +5 -0
- package/src/storage/adapters/remoteStorage.ts +70 -0
- package/src/storage/base.ts +28 -0
- package/src/storage/index.ts +9 -0
- package/src/storage/jsonl-entry.ts +9 -0
- package/src/storage/jsonl.ts +111 -0
- package/src/storage/memory.ts +49 -0
- package/src/storage/mongodb-entry.ts +9 -0
- package/src/storage/mongodb.ts +132 -0
- package/src/storage/protocol.ts +170 -0
- package/src/storage/transport.ts +95 -0
- package/src/types.ts +76 -0
- package/src/useStore.ts +83 -0
- package/tests/atomCreator.test.ts +153 -0
- package/tests/createStore.test.ts +126 -0
- package/tests/debouncer.test.ts +125 -0
- package/tests/server.test.ts +158 -0
- package/tests/storage/jsonl.test.ts +132 -0
- package/tests/storage/memory.test.ts +101 -0
- package/tests/storage/mongodb.test.ts +40 -0
- package/tests/storage/remoteStorage.test.ts +126 -0
- package/tests/useStore.test.tsx +147 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +53 -0
- package/vitest.config.ts +14 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { WritableAtom, Atom } from 'jotai';
|
|
2
|
+
|
|
3
|
+
// ========================================
|
|
4
|
+
// Atom Configuration Types
|
|
5
|
+
// ========================================
|
|
6
|
+
|
|
7
|
+
export type AtomType = 'string' | 'number' | 'boolean' | 'array' | 'object';
|
|
8
|
+
|
|
9
|
+
export interface AtomConfig<T = unknown> {
|
|
10
|
+
key: string;
|
|
11
|
+
defaultValue: T;
|
|
12
|
+
type?: AtomType;
|
|
13
|
+
/** If true, save immediately without debounce */
|
|
14
|
+
immediate?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AtomConfigs = readonly AtomConfig[];
|
|
18
|
+
|
|
19
|
+
// ========================================
|
|
20
|
+
// Store Types
|
|
21
|
+
// ========================================
|
|
22
|
+
|
|
23
|
+
export interface PersistedAtomWithLoading<T> {
|
|
24
|
+
atom: WritableAtom<T, [update: T | ((prev: T) => T)], void>;
|
|
25
|
+
loadingAtom: Atom<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
export type StoreAtoms = Record<string, WritableAtom<any, [any], void>>;
|
|
30
|
+
export type StoreLoadingAtoms = Record<string, Atom<boolean>>;
|
|
31
|
+
|
|
32
|
+
export interface Store {
|
|
33
|
+
entityId: string;
|
|
34
|
+
config: AtomConfigs;
|
|
35
|
+
atoms: StoreAtoms;
|
|
36
|
+
loadingAtoms: StoreLoadingAtoms;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ========================================
|
|
40
|
+
// Hook Return Types
|
|
41
|
+
// ========================================
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
export type UseStoreReturn = Record<string, any> & {
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ========================================
|
|
49
|
+
// Storage Options
|
|
50
|
+
// ========================================
|
|
51
|
+
|
|
52
|
+
export interface StorageOptions {
|
|
53
|
+
debounceMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface JsonlStorageOptions extends StorageOptions {
|
|
57
|
+
dir?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MongoStorageOptions extends StorageOptions {
|
|
61
|
+
url: string;
|
|
62
|
+
database?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MemoryStorageOptions extends StorageOptions {}
|
|
66
|
+
|
|
67
|
+
// ========================================
|
|
68
|
+
// Backend Config (shared between protocol and handlers)
|
|
69
|
+
// ========================================
|
|
70
|
+
|
|
71
|
+
export interface BackendConfig {
|
|
72
|
+
backend?: 'mongodb' | 'jsonl';
|
|
73
|
+
mongoUrl?: string;
|
|
74
|
+
mongoDb?: string;
|
|
75
|
+
jsonlDir?: string;
|
|
76
|
+
}
|
package/src/useStore.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useAtom } from 'jotai';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { getDefaultStore } from 'jotai';
|
|
4
|
+
import type { Store, UseStoreReturn } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React Hook to use a store
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const store = createStore('user-123', [
|
|
12
|
+
* { key: 'name', defaultValue: '' },
|
|
13
|
+
* { key: 'age', defaultValue: 0 },
|
|
14
|
+
* ], { storage });
|
|
15
|
+
*
|
|
16
|
+
* function UserProfile() {
|
|
17
|
+
* const { name, age, setName, setAge, isLoading } = useStore(store);
|
|
18
|
+
* // ...
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function useStore(store: Store): UseStoreReturn {
|
|
23
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
// Subscribe to loading states
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const storeInstance = getDefaultStore();
|
|
28
|
+
const loadingAtoms = Object.values(store.loadingAtoms);
|
|
29
|
+
|
|
30
|
+
if (loadingAtoms.length === 0) {
|
|
31
|
+
setIsLoading(false);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const checkLoadingStatus = () => {
|
|
36
|
+
const loadingStates = loadingAtoms.map((atom) => storeInstance.get(atom));
|
|
37
|
+
const anyLoading = loadingStates.some((loading) => loading === true);
|
|
38
|
+
setIsLoading(anyLoading);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
checkLoadingStatus();
|
|
42
|
+
|
|
43
|
+
const unsubscribers = loadingAtoms.map((atom) =>
|
|
44
|
+
storeInstance.sub(atom, checkLoadingStatus)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
unsubscribers.forEach((unsub) => unsub());
|
|
49
|
+
};
|
|
50
|
+
}, [store.loadingAtoms]);
|
|
51
|
+
|
|
52
|
+
// Build a dynamic result based on config
|
|
53
|
+
// Note: React hooks must be called unconditionally and in the same order
|
|
54
|
+
const result = buildStoreResult(store, isLoading);
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build store result object - calls useAtom for each atom
|
|
61
|
+
* This must be called unconditionally with the same atoms each render
|
|
62
|
+
*/
|
|
63
|
+
function buildStoreResult(store: Store, isLoading: boolean): UseStoreReturn {
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
const result: any = { isLoading };
|
|
66
|
+
|
|
67
|
+
// Iterate over store.config which is always stable
|
|
68
|
+
// Each config always has a corresponding atom in store.atoms (created together in createStore)
|
|
69
|
+
for (let i = 0; i < store.config.length; i++) {
|
|
70
|
+
const config = store.config[i];
|
|
71
|
+
const atom = store.atoms[config.key];
|
|
72
|
+
// atom always exists - created in createStore for every config
|
|
73
|
+
const [value, setter] = useAtom(atom);
|
|
74
|
+
result[config.key] = value;
|
|
75
|
+
result[`set${capitalize(config.key)}`] = setter;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function capitalize(str: string): string {
|
|
82
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
83
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { getDefaultStore } from 'jotai';
|
|
3
|
+
import { createPersistedAtom } from '../src/atomCreator';
|
|
4
|
+
import type { Storage } from '../src/storage/base';
|
|
5
|
+
|
|
6
|
+
function createMockStorage(initialData: Record<string, unknown> = {}): Storage {
|
|
7
|
+
const data = new Map(Object.entries(initialData));
|
|
8
|
+
return {
|
|
9
|
+
async get<T>(key: string): Promise<T | null> {
|
|
10
|
+
return (data.get(key) as T) ?? null;
|
|
11
|
+
},
|
|
12
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
13
|
+
data.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
async remove(key: string): Promise<void> {
|
|
16
|
+
data.delete(key);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('createPersistedAtom', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('basic creation', () => {
|
|
27
|
+
it('should create atom with default value', () => {
|
|
28
|
+
const storage = createMockStorage();
|
|
29
|
+
const result = createPersistedAtom(
|
|
30
|
+
{ key: 'theme', defaultValue: 'dark' },
|
|
31
|
+
storage
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(result.atom).toBeDefined();
|
|
35
|
+
expect(result.loadingAtom).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should have different atoms for different keys', () => {
|
|
39
|
+
const storage = createMockStorage();
|
|
40
|
+
const result1 = createPersistedAtom({ key: 'a', defaultValue: 1 }, storage);
|
|
41
|
+
const result2 = createPersistedAtom({ key: 'b', defaultValue: 2 }, storage);
|
|
42
|
+
|
|
43
|
+
expect(result1.atom).not.toBe(result2.atom);
|
|
44
|
+
expect(result1.loadingAtom).not.toBe(result2.loadingAtom);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should have loading atom starting as true', () => {
|
|
48
|
+
const storage = createMockStorage();
|
|
49
|
+
const { loadingAtom } = createPersistedAtom(
|
|
50
|
+
{ key: 'theme', defaultValue: 'dark' },
|
|
51
|
+
storage
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const store = getDefaultStore();
|
|
55
|
+
expect(store.get(loadingAtom)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('atom read', () => {
|
|
60
|
+
it('should return default value via read', () => {
|
|
61
|
+
const storage = createMockStorage();
|
|
62
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
63
|
+
{ key: 'theme', defaultValue: 'dark' },
|
|
64
|
+
storage
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const store = getDefaultStore();
|
|
68
|
+
expect(store.get(persistedAtom)).toBe('dark');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('atom write with immediate', () => {
|
|
73
|
+
it('should update value immediately in store', () => {
|
|
74
|
+
const storage = createMockStorage();
|
|
75
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
76
|
+
{ key: 'theme', defaultValue: 'dark', immediate: true },
|
|
77
|
+
storage
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const store = getDefaultStore();
|
|
81
|
+
store.set(persistedAtom, 'light');
|
|
82
|
+
expect(store.get(persistedAtom)).toBe('light');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('atom write with debounce', () => {
|
|
87
|
+
it('should update value in store immediately', () => {
|
|
88
|
+
const storage = createMockStorage();
|
|
89
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
90
|
+
{ key: 'theme', defaultValue: 'dark', immediate: false },
|
|
91
|
+
storage
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const store = getDefaultStore();
|
|
95
|
+
store.set(persistedAtom, 'light');
|
|
96
|
+
expect(store.get(persistedAtom)).toBe('light');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('function updates', () => {
|
|
101
|
+
it('should handle function updates correctly', () => {
|
|
102
|
+
const storage = createMockStorage();
|
|
103
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
104
|
+
{ key: 'count', defaultValue: 5 },
|
|
105
|
+
storage
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const store = getDefaultStore();
|
|
109
|
+
expect(store.get(persistedAtom)).toBe(5);
|
|
110
|
+
|
|
111
|
+
store.set(persistedAtom, (prev) => (prev as number) + 1);
|
|
112
|
+
expect(store.get(persistedAtom)).toBe(6);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('type handling', () => {
|
|
117
|
+
it('should handle string type', () => {
|
|
118
|
+
const storage = createMockStorage();
|
|
119
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
120
|
+
{ key: 'name', defaultValue: '', type: 'string' },
|
|
121
|
+
storage
|
|
122
|
+
);
|
|
123
|
+
expect(persistedAtom).toBeDefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should handle number type', () => {
|
|
127
|
+
const storage = createMockStorage();
|
|
128
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
129
|
+
{ key: 'count', defaultValue: 0, type: 'number' },
|
|
130
|
+
storage
|
|
131
|
+
);
|
|
132
|
+
expect(persistedAtom).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle array type', () => {
|
|
136
|
+
const storage = createMockStorage();
|
|
137
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
138
|
+
{ key: 'items', defaultValue: [], type: 'array' },
|
|
139
|
+
storage
|
|
140
|
+
);
|
|
141
|
+
expect(persistedAtom).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should handle object type', () => {
|
|
145
|
+
const storage = createMockStorage();
|
|
146
|
+
const { atom: persistedAtom } = createPersistedAtom(
|
|
147
|
+
{ key: 'data', defaultValue: {}, type: 'object' },
|
|
148
|
+
storage
|
|
149
|
+
);
|
|
150
|
+
expect(persistedAtom).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { getDefaultStore } from 'jotai';
|
|
3
|
+
import { createStore } from '../src/createStore';
|
|
4
|
+
import { memoryStorage } from '../src/storage/memory';
|
|
5
|
+
|
|
6
|
+
describe('createStore', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// Reset store between tests
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('basic creation', () => {
|
|
12
|
+
it('should create store with single config', () => {
|
|
13
|
+
const adapter = memoryStorage();
|
|
14
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
15
|
+
|
|
16
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
17
|
+
|
|
18
|
+
expect(store.entityId).toBe('entity-1');
|
|
19
|
+
expect(store.config).toBe(config);
|
|
20
|
+
expect(store.atoms.theme).toBeDefined();
|
|
21
|
+
expect(store.loadingAtoms.theme).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should create store with multiple configs', () => {
|
|
25
|
+
const adapter = memoryStorage();
|
|
26
|
+
const config = [
|
|
27
|
+
{ key: 'theme', defaultValue: 'dark' },
|
|
28
|
+
{ key: 'name', defaultValue: '' },
|
|
29
|
+
{ key: 'count', defaultValue: 0 },
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
33
|
+
|
|
34
|
+
expect(store.atoms.theme).toBeDefined();
|
|
35
|
+
expect(store.atoms.name).toBeDefined();
|
|
36
|
+
expect(store.atoms.count).toBeDefined();
|
|
37
|
+
expect(store.loadingAtoms.theme).toBeDefined();
|
|
38
|
+
expect(store.loadingAtoms.name).toBeDefined();
|
|
39
|
+
expect(store.loadingAtoms.count).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should throw when no storage provided', () => {
|
|
43
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
44
|
+
|
|
45
|
+
expect(() => {
|
|
46
|
+
createStore('entity-1', config);
|
|
47
|
+
}).toThrow('Storage adapter is required');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should use default debounceMs of 800', () => {
|
|
51
|
+
const adapter = memoryStorage();
|
|
52
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
53
|
+
|
|
54
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
55
|
+
|
|
56
|
+
expect(store.atoms.theme).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should accept custom debounceMs', () => {
|
|
60
|
+
const adapter = memoryStorage();
|
|
61
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
62
|
+
|
|
63
|
+
const store = createStore('entity-1', config, {
|
|
64
|
+
storage: adapter,
|
|
65
|
+
debounceMs: 500,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(store.atoms.theme).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('entityId handling', () => {
|
|
73
|
+
it('should store entityId in returned store', () => {
|
|
74
|
+
const adapter = memoryStorage();
|
|
75
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
76
|
+
|
|
77
|
+
const store = createStore('novel-123', config, { storage: adapter });
|
|
78
|
+
|
|
79
|
+
expect(store.entityId).toBe('novel-123');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('immediate flag', () => {
|
|
84
|
+
it('should pass immediate flag to atom creator', () => {
|
|
85
|
+
const adapter = memoryStorage();
|
|
86
|
+
const config = [
|
|
87
|
+
{ key: 'normal', defaultValue: '' },
|
|
88
|
+
{ key: 'critical', defaultValue: '', immediate: true },
|
|
89
|
+
] as const;
|
|
90
|
+
|
|
91
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
92
|
+
|
|
93
|
+
expect(store.atoms.normal).toBeDefined();
|
|
94
|
+
expect(store.atoms.critical).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('atom access via jotai store', () => {
|
|
99
|
+
it('should allow atom access through jotai store', () => {
|
|
100
|
+
const adapter = memoryStorage();
|
|
101
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
102
|
+
|
|
103
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
104
|
+
|
|
105
|
+
const jotaiStore = getDefaultStore();
|
|
106
|
+
|
|
107
|
+
jotaiStore.set(store.atoms.theme, 'light');
|
|
108
|
+
expect(jotaiStore.get(store.atoms.theme)).toBe('light');
|
|
109
|
+
|
|
110
|
+
jotaiStore.set(store.atoms.theme, (prev) => (prev as string) + '-mode');
|
|
111
|
+
expect(jotaiStore.get(store.atoms.theme)).toBe('light-mode');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('loading atoms', () => {
|
|
116
|
+
it('should have loading atoms that start true', () => {
|
|
117
|
+
const adapter = memoryStorage();
|
|
118
|
+
const config = [{ key: 'theme', defaultValue: 'dark' }] as const;
|
|
119
|
+
|
|
120
|
+
const store = createStore('entity-1', config, { storage: adapter });
|
|
121
|
+
|
|
122
|
+
const jotaiStore = getDefaultStore();
|
|
123
|
+
expect(jotaiStore.get(store.loadingAtoms.theme)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createDebouncer, DebouncerMap } from '../src/debouncer';
|
|
3
|
+
|
|
4
|
+
describe('Debouncer', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should delay function execution', () => {
|
|
10
|
+
const debouncer = createDebouncer(100);
|
|
11
|
+
const fn = vi.fn();
|
|
12
|
+
|
|
13
|
+
debouncer.run(fn);
|
|
14
|
+
expect(fn).not.toHaveBeenCalled();
|
|
15
|
+
|
|
16
|
+
vi.advanceTimersByTime(100);
|
|
17
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should reset timer on subsequent calls', () => {
|
|
21
|
+
const debouncer = createDebouncer(100);
|
|
22
|
+
const fn = vi.fn();
|
|
23
|
+
|
|
24
|
+
debouncer.run(fn);
|
|
25
|
+
vi.advanceTimersByTime(50);
|
|
26
|
+
debouncer.run(fn);
|
|
27
|
+
|
|
28
|
+
expect(fn).not.toHaveBeenCalled();
|
|
29
|
+
|
|
30
|
+
vi.advanceTimersByTime(100);
|
|
31
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should cancel pending execution', () => {
|
|
35
|
+
const debouncer = createDebouncer(100);
|
|
36
|
+
const fn = vi.fn();
|
|
37
|
+
|
|
38
|
+
debouncer.run(fn);
|
|
39
|
+
debouncer.cancel();
|
|
40
|
+
|
|
41
|
+
vi.advanceTimersByTime(200);
|
|
42
|
+
expect(fn).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should expose ms property', () => {
|
|
46
|
+
const debouncer = createDebouncer(500);
|
|
47
|
+
expect(debouncer.ms).toBe(500);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('DebouncerMap', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.useFakeTimers();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should create debouncer for new key', () => {
|
|
57
|
+
const map = new DebouncerMap(100);
|
|
58
|
+
const debouncer1 = map.getDebouncer('key1');
|
|
59
|
+
const debouncer2 = map.getDebouncer('key2');
|
|
60
|
+
|
|
61
|
+
expect(debouncer1).not.toBe(debouncer2);
|
|
62
|
+
expect(debouncer1.ms).toBe(100);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return same debouncer for same key', () => {
|
|
66
|
+
const map = new DebouncerMap(100);
|
|
67
|
+
const debouncer1 = map.getDebouncer('key1');
|
|
68
|
+
const debouncer2 = map.getDebouncer('key1');
|
|
69
|
+
|
|
70
|
+
expect(debouncer1).toBe(debouncer2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should debounce function calls per key', () => {
|
|
74
|
+
const map = new DebouncerMap(100);
|
|
75
|
+
const fn1 = vi.fn();
|
|
76
|
+
const fn2 = vi.fn();
|
|
77
|
+
|
|
78
|
+
map.debounce('key1', fn1);
|
|
79
|
+
map.debounce('key2', fn2);
|
|
80
|
+
|
|
81
|
+
vi.advanceTimersByTime(100);
|
|
82
|
+
|
|
83
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(fn2).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should cancel debounced call for specific key', () => {
|
|
88
|
+
const map = new DebouncerMap(100);
|
|
89
|
+
const fn = vi.fn();
|
|
90
|
+
|
|
91
|
+
map.debounce('key1', fn);
|
|
92
|
+
map.cancel('key1');
|
|
93
|
+
|
|
94
|
+
vi.advanceTimersByTime(200);
|
|
95
|
+
expect(fn).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should cancel all pending debounced calls', () => {
|
|
99
|
+
const map = new DebouncerMap(100);
|
|
100
|
+
const fn1 = vi.fn();
|
|
101
|
+
const fn2 = vi.fn();
|
|
102
|
+
|
|
103
|
+
map.debounce('key1', fn1);
|
|
104
|
+
map.debounce('key2', fn2);
|
|
105
|
+
map.cancelAll();
|
|
106
|
+
|
|
107
|
+
vi.advanceTimersByTime(200);
|
|
108
|
+
|
|
109
|
+
expect(fn1).not.toHaveBeenCalled();
|
|
110
|
+
expect(fn2).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should use custom ms per debounce call', () => {
|
|
114
|
+
const map = new DebouncerMap(100);
|
|
115
|
+
const fn = vi.fn();
|
|
116
|
+
|
|
117
|
+
map.debounce('key1', fn, 500);
|
|
118
|
+
|
|
119
|
+
vi.advanceTimersByTime(100);
|
|
120
|
+
expect(fn).not.toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
vi.advanceTimersByTime(400);
|
|
123
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
});
|