@ursalock/zustand 0.3.0 → 0.4.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/dist/vault.js DELETED
@@ -1,261 +0,0 @@
1
- /**
2
- * vault() middleware - Drop-in replacement for persist()
3
- * Adds E2EE encryption and cloud sync to Zustand stores
4
- *
5
- * Supports two encryption modes:
6
- * - Legacy: recoveryKey string (Argon2id key derivation)
7
- * - New: cipherJwk from ZKCredentials (PRF-derived)
8
- *
9
- * Type pattern follows zustand's persist middleware:
10
- * - Simple internal implementation type (VaultImpl)
11
- * - Complex public API type (Vault)
12
- * - Cast at export: `vaultImpl as unknown as Vault`
13
- */
14
- import { createVaultStorage } from "./storage.js";
15
- import { createSyncEngine } from "./sync.js";
16
- /** Log a caught error from a fire-and-forget promise */
17
- const logCatch = (context) => (err) => console.error(`[ursalock] ${context}:`, err);
18
- /** Helper to check if using JWK mode */
19
- function isJwkMode(options) {
20
- return "cipherJwk" in options;
21
- }
22
- /**
23
- * Internal vault implementation
24
- * Uses simplified types - complex types applied at export
25
- */
26
- const vaultImpl = (config, baseOptions) => (set, get, api) => {
27
- const options = {
28
- partialize: (state) => state,
29
- merge: (persistedState, currentState) => ({
30
- ...currentState,
31
- ...persistedState,
32
- }),
33
- syncInterval: 30000,
34
- ...baseOptions,
35
- };
36
- const { name, server, getToken, partialize, merge, onRehydrateStorage, skipHydration = false, syncInterval, } = options;
37
- // Create encrypted storage based on mode
38
- const storage = options.storage ?? createVaultStorage(isJwkMode(options)
39
- ? { cipherJwk: options.cipherJwk, prefix: options.prefix }
40
- : { recoveryKey: options.recoveryKey, prefix: options.prefix });
41
- // State
42
- let hasHydrated = false;
43
- let localUpdatedAt = 0; // Start at 0 - server data wins until we have local changes
44
- let hasLocalData = false; // Track if we loaded data from local storage
45
- let syncDebounceTimer = null;
46
- const hydrationListeners = new Set();
47
- const finishHydrationListeners = new Set();
48
- // Create sync engine (if server configured)
49
- let syncEngine = null;
50
- if (server && getToken) {
51
- syncEngine = createSyncEngine({
52
- serverUrl: server,
53
- name,
54
- getToken,
55
- onServerData: (data, _salt, updatedAt) => {
56
- // Server has newer data, update local store
57
- // Only pull if we haven't made local changes since last sync
58
- if (localUpdatedAt > updatedAt) {
59
- // Local is actually newer - don't overwrite, push instead
60
- void syncEngine?.push().catch(logCatch("Push after local-newer conflict"));
61
- return;
62
- }
63
- try {
64
- const parsed = JSON.parse(data);
65
- const merged = merge(parsed, get());
66
- set(merged, true);
67
- localUpdatedAt = updatedAt;
68
- // Also persist to local storage to keep in sync
69
- void storage.setItem(name, JSON.stringify(partialize({ ...get() }))).catch(logCatch("Persist server data to local storage"));
70
- }
71
- catch (err) {
72
- console.error("[ursalock] Failed to parse server data:", err);
73
- }
74
- },
75
- getLocalData: () => {
76
- // Get current encrypted local data
77
- const state = partialize({ ...get() });
78
- return {
79
- data: JSON.stringify(state),
80
- salt: "", // Salt is handled by storage layer
81
- updatedAt: localUpdatedAt,
82
- };
83
- },
84
- });
85
- }
86
- // Persist to storage and trigger debounced sync
87
- const persistState = async () => {
88
- const state = partialize({ ...get() });
89
- await storage.setItem(name, JSON.stringify(state));
90
- localUpdatedAt = Date.now();
91
- // Debounced sync after changes (3 seconds)
92
- if (syncEngine) {
93
- if (syncDebounceTimer) {
94
- clearTimeout(syncDebounceTimer);
95
- }
96
- syncDebounceTimer = setTimeout(() => {
97
- syncDebounceTimer = null;
98
- void syncEngine?.sync().catch(logCatch("Debounced sync"));
99
- }, 3000);
100
- }
101
- };
102
- // Hydrate from storage
103
- const rehydrate = async () => {
104
- hasHydrated = false;
105
- hydrationListeners.forEach((cb) => cb(get()));
106
- const postRehydrationCallback = onRehydrateStorage?.(get()) || undefined;
107
- try {
108
- const stored = await storage.getItem(name);
109
- if (stored) {
110
- const parsed = JSON.parse(stored);
111
- const merged = merge(parsed, get());
112
- set(merged, true);
113
- hasLocalData = true;
114
- // Don't set localUpdatedAt here - keep at 0 so first sync pulls from server
115
- // localUpdatedAt will be set properly after first successful sync or local change
116
- }
117
- hasHydrated = true;
118
- postRehydrationCallback?.(get(), undefined);
119
- finishHydrationListeners.forEach((cb) => cb(get()));
120
- }
121
- catch (error) {
122
- postRehydrationCallback?.(undefined, error);
123
- }
124
- };
125
- // Sync methods (delegate to sync engine)
126
- const sync = async () => {
127
- if (!syncEngine)
128
- return;
129
- await syncEngine.sync();
130
- };
131
- const push = async () => {
132
- if (!syncEngine)
133
- return;
134
- await syncEngine.push();
135
- };
136
- const pull = async () => {
137
- if (!syncEngine)
138
- return false;
139
- return syncEngine.pull();
140
- };
141
- const getSyncStatus = () => {
142
- if (!syncEngine)
143
- return "idle";
144
- return syncEngine.getState().status;
145
- };
146
- const hasPendingChanges = () => {
147
- if (!syncEngine)
148
- return false;
149
- return syncEngine.getState().pendingChanges;
150
- };
151
- // Clear storage
152
- const clearStorage = async () => {
153
- await storage.removeItem(name);
154
- syncEngine?.clearQueue();
155
- };
156
- const savedSetState = api.setState;
157
- api.setState = ((state, replace) => {
158
- if (replace) {
159
- savedSetState(state, true);
160
- }
161
- else {
162
- savedSetState(state);
163
- }
164
- void persistState().catch(logCatch("Persist state"));
165
- });
166
- // Create store with wrapped set
167
- const configResult = config(((partial, replace) => {
168
- if (replace) {
169
- set(partial, true);
170
- }
171
- else {
172
- set(partial);
173
- }
174
- void persistState().catch(logCatch("Persist state"));
175
- }), get, api);
176
- // Extend API with vault methods
177
- const storeWithVault = api;
178
- storeWithVault.vault = {
179
- sync,
180
- push,
181
- pull,
182
- rehydrate,
183
- hasHydrated: () => hasHydrated,
184
- getSyncStatus,
185
- hasPendingChanges,
186
- clearStorage,
187
- onHydrate: (cb) => {
188
- hydrationListeners.add(cb);
189
- return () => hydrationListeners.delete(cb);
190
- },
191
- onFinishHydration: (cb) => {
192
- finishHydrationListeners.add(cb);
193
- return () => finishHydrationListeners.delete(cb);
194
- },
195
- };
196
- // Auto-hydrate on init (unless skipHydration)
197
- if (!skipHydration) {
198
- void rehydrate().then(() => {
199
- // Sync immediately after hydration to get latest server data
200
- if (syncEngine) {
201
- void syncEngine.sync().catch(logCatch("Initial sync after hydration"));
202
- }
203
- }).catch(logCatch("Auto-rehydration"));
204
- }
205
- else {
206
- // Even with skipHydration, mark as hydrated to allow persistence
207
- hasHydrated = true;
208
- }
209
- // Setup sync interval (if server configured)
210
- let syncIntervalId = null;
211
- if (server && syncInterval > 0) {
212
- syncIntervalId = setInterval(() => void sync().catch(logCatch("Periodic sync")), syncInterval);
213
- }
214
- // Expose destroy method to clean up interval
215
- const vaultApi = storeWithVault.vault;
216
- vaultApi.destroy = () => {
217
- if (syncIntervalId) {
218
- clearInterval(syncIntervalId);
219
- syncIntervalId = null;
220
- }
221
- if (syncDebounceTimer) {
222
- clearTimeout(syncDebounceTimer);
223
- syncDebounceTimer = null;
224
- }
225
- };
226
- return configResult;
227
- };
228
- /**
229
- * vault() middleware - Add E2EE encrypted persistence to Zustand
230
- *
231
- * @example Using CipherJWK from ZKCredentials (recommended)
232
- * ```ts
233
- * const useStore = create(
234
- * vault(
235
- * (set) => ({
236
- * count: 0,
237
- * increment: () => set((s) => ({ count: s.count + 1 })),
238
- * }),
239
- * {
240
- * name: 'my-store',
241
- * cipherJwk: credential.cipherJwk, // From ZKCredentials
242
- * server: 'https://vault.example.com', // optional
243
- * }
244
- * )
245
- * )
246
- * ```
247
- *
248
- * @example Using recovery key (legacy)
249
- * ```ts
250
- * const useStore = create(
251
- * vault(
252
- * (set) => ({ ... }),
253
- * {
254
- * name: 'my-store',
255
- * recoveryKey: 'ABCD-EFGH-...',
256
- * }
257
- * )
258
- * )
259
- * ```
260
- */
261
- export const vault = vaultImpl;