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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +45 -0
  2. package/CONTEXT.md +53 -0
  3. package/README.md +223 -0
  4. package/dist/cli.js +32577 -0
  5. package/dist/index.d.mts +232 -0
  6. package/dist/index.d.ts +232 -0
  7. package/dist/index.mjs +430 -0
  8. package/dist/package.json +62 -0
  9. package/dist/server/entry.d.mts +94 -0
  10. package/dist/server/entry.d.ts +94 -0
  11. package/dist/server/entry.js +573 -0
  12. package/dist/server/entry.mjs +533 -0
  13. package/dist/server-V7WCW4ZB.mjs +530 -0
  14. package/dist/storage/jsonl-entry.d.mts +42 -0
  15. package/dist/storage/jsonl-entry.d.ts +42 -0
  16. package/dist/storage/jsonl-entry.js +112 -0
  17. package/dist/storage/jsonl-entry.mjs +74 -0
  18. package/dist/storage/mongodb-entry.d.mts +40 -0
  19. package/dist/storage/mongodb-entry.d.ts +40 -0
  20. package/dist/storage/mongodb-entry.js +114 -0
  21. package/dist/storage/mongodb-entry.mjs +86 -0
  22. package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
  23. package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
  24. package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
  25. package/docs/IMPLEMENTATION.md +333 -0
  26. package/docs/PLAN.md +153 -0
  27. package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
  28. package/docs/SIDECAR_SERVER.md +184 -0
  29. package/package.json +62 -0
  30. package/scripts/adapt-dist-package.js +33 -0
  31. package/src/atomCreator.ts +76 -0
  32. package/src/createStore.ts +77 -0
  33. package/src/debouncer.ts +84 -0
  34. package/src/index.ts +35 -0
  35. package/src/server/cli.ts +62 -0
  36. package/src/server/entityIdCache.ts +57 -0
  37. package/src/server/entry.ts +12 -0
  38. package/src/server/handlers.ts +271 -0
  39. package/src/server/index.ts +182 -0
  40. package/src/server/router.ts +74 -0
  41. package/src/server.ts +5 -0
  42. package/src/storage/adapters/remoteStorage.ts +70 -0
  43. package/src/storage/base.ts +28 -0
  44. package/src/storage/index.ts +9 -0
  45. package/src/storage/jsonl-entry.ts +9 -0
  46. package/src/storage/jsonl.ts +111 -0
  47. package/src/storage/memory.ts +49 -0
  48. package/src/storage/mongodb-entry.ts +9 -0
  49. package/src/storage/mongodb.ts +132 -0
  50. package/src/storage/protocol.ts +170 -0
  51. package/src/storage/transport.ts +95 -0
  52. package/src/types.ts +76 -0
  53. package/src/useStore.ts +83 -0
  54. package/tests/atomCreator.test.ts +153 -0
  55. package/tests/createStore.test.ts +126 -0
  56. package/tests/debouncer.test.ts +125 -0
  57. package/tests/server.test.ts +158 -0
  58. package/tests/storage/jsonl.test.ts +132 -0
  59. package/tests/storage/memory.test.ts +101 -0
  60. package/tests/storage/mongodb.test.ts +40 -0
  61. package/tests/storage/remoteStorage.test.ts +126 -0
  62. package/tests/useStore.test.tsx +147 -0
  63. package/tsconfig.json +18 -0
  64. package/tsup.config.ts +53 -0
  65. 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
+ }
@@ -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
+ });