@drakkar.software/starfish-client 1.5.0 → 1.7.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.
@@ -30,6 +30,26 @@ export interface CreateStarfishStoreOptions {
30
30
  devtools?: boolean | DevtoolsOptions;
31
31
  /** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */
32
32
  produce?: <T>(base: T, recipe: (draft: T) => T | void) => T;
33
+ /**
34
+ * Called when remote data arrives via `pull()` — **not** called for local `set()` writes.
35
+ *
36
+ * Use this to restore domain stores after a pull without worrying about feedback loops.
37
+ * The callback fires **after** the Starfish store state is updated, so the store already
38
+ * reflects the new data when this runs.
39
+ *
40
+ * Replaces the manual `isRestoring` flag pattern:
41
+ * ```ts
42
+ * createStarfishStore({
43
+ * name: "app",
44
+ * syncManager,
45
+ * onRemoteUpdate: (data) => {
46
+ * taskStore.setState({ tasks: data.tasks as Task[] })
47
+ * settingsStore.setState({ settings: data.settings as Settings })
48
+ * },
49
+ * })
50
+ * ```
51
+ */
52
+ onRemoteUpdate?: (data: Record<string, unknown>) => void;
33
53
  }
34
54
  export type { DevtoolsOptions };
35
55
  export declare function createStarfishStore(options: CreateStarfishStoreOptions): StoreApi<StarfishStore>;
@@ -48,6 +68,22 @@ export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishSto
48
68
  export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
49
69
  /** Use the derived sync status (synced | syncing | pending | error | offline). */
50
70
  export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
71
+ /**
72
+ * Subscribe to sync status changes outside of React.
73
+ *
74
+ * Framework-agnostic — works in React Native, Node.js, or anywhere hooks are unavailable.
75
+ * The callback is invoked immediately with the current status and then on every change.
76
+ *
77
+ * ```ts
78
+ * const unsub = subscribeSyncStatus(store, (status) => {
79
+ * updateStatusBar(status)
80
+ * })
81
+ *
82
+ * // Later, to stop listening:
83
+ * unsub()
84
+ * ```
85
+ */
86
+ export declare function subscribeSyncStatus(store: StoreApi<StarfishStore>, callback: (status: SyncStatus) => void): () => void;
51
87
  /** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
52
88
  export declare function useCrossTabSync(store: StoreApi<StarfishStore>, name: string): void;
53
89
  /** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
@@ -19,7 +19,11 @@ export function createStarfishStore(options) {
19
19
  set({ syncing: true, error: null }, false, "pull/start");
20
20
  try {
21
21
  await syncManager.pull();
22
- set({ data: syncManager.getData(), syncing: false }, false, "pull/success");
22
+ const newData = syncManager.getData();
23
+ set({ data: newData, syncing: false }, false, "pull/success");
24
+ // Fire after state update so domain stores can read the updated Starfish state if needed.
25
+ // Calling set() inside onRemoteUpdate does NOT re-enter pull(), so no feedback loop.
26
+ options.onRemoteUpdate?.(newData);
23
27
  }
24
28
  catch (err) {
25
29
  set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
@@ -118,6 +122,32 @@ export function useStarfishData(store, selector) {
118
122
  export function useSyncStatus(store) {
119
123
  return useStore(store, deriveSyncStatus);
120
124
  }
125
+ /**
126
+ * Subscribe to sync status changes outside of React.
127
+ *
128
+ * Framework-agnostic — works in React Native, Node.js, or anywhere hooks are unavailable.
129
+ * The callback is invoked immediately with the current status and then on every change.
130
+ *
131
+ * ```ts
132
+ * const unsub = subscribeSyncStatus(store, (status) => {
133
+ * updateStatusBar(status)
134
+ * })
135
+ *
136
+ * // Later, to stop listening:
137
+ * unsub()
138
+ * ```
139
+ */
140
+ export function subscribeSyncStatus(store, callback) {
141
+ let prev = deriveSyncStatus(store.getState());
142
+ callback(prev);
143
+ return store.subscribe((state) => {
144
+ const next = deriveSyncStatus(state);
145
+ if (next !== prev) {
146
+ prev = next;
147
+ callback(next);
148
+ }
149
+ });
150
+ }
121
151
  /** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
122
152
  export function useCrossTabSync(store, name) {
123
153
  useEffect(() => {
@@ -209,15 +239,9 @@ export function useSyncInit(config) {
209
239
  name: config.storeName ?? "sync",
210
240
  syncManager,
211
241
  storage: config.storage,
212
- });
213
- // Subscribe to data changes from pulls (not local sets)
214
- let lastDataRef = newStore.getState().data;
215
- const unsub = newStore.subscribe((state) => {
216
- const data = state.data;
217
- // Only call onData when data changes and store is not dirty
218
- // (dirty = false means data came from a pull, not a local set)
219
- if (data !== lastDataRef && !state.dirty) {
220
- lastDataRef = data;
242
+ // onRemoteUpdate fires only for pull() results, never for local set() writes —
243
+ // so no isRestoring flag is needed.
244
+ onRemoteUpdate: (data) => {
221
245
  try {
222
246
  onDataRef.current?.(data);
223
247
  }
@@ -226,16 +250,12 @@ export function useSyncInit(config) {
226
250
  error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
227
251
  });
228
252
  }
229
- }
230
- else {
231
- lastDataRef = data;
232
- }
253
+ },
233
254
  });
234
255
  setStore(newStore);
235
256
  // Initial pull — errors are stored in state.error by the pull() action
236
257
  newStore.getState().pull().catch(() => { });
237
258
  return () => {
238
- unsub();
239
259
  setStore(null);
240
260
  };
241
261
  // Intentionally depend on serializable config values, not the object reference
@@ -0,0 +1,131 @@
1
+ import type { StoreApi } from "zustand/vanilla";
2
+ import type { StarfishStore } from "./bindings/zustand.js";
3
+ import type { SyncManager } from "./sync.js";
4
+ export interface DebouncedSyncOptions {
5
+ /**
6
+ * How long to wait after the last `notify()` call before pushing (default: 2000 ms).
7
+ * Shorter values reduce latency; longer values batch more edits into a single push.
8
+ */
9
+ delayMs?: number;
10
+ /**
11
+ * Emit a warning when the estimated encrypted payload exceeds this byte count (default: 900 KB).
12
+ * The estimate multiplies the JSON size by 1.34 (base64 overhead for encrypted blobs).
13
+ * Set to `Infinity` to disable.
14
+ */
15
+ warnBytes?: number;
16
+ /**
17
+ * Block the push when the estimated encrypted payload exceeds this byte count (default: 1 MB).
18
+ * Prevents cryptic 413 errors from the server. Set to `Infinity` to disable.
19
+ */
20
+ maxBytes?: number;
21
+ /**
22
+ * Serialize store data to a sync document before pushing.
23
+ * Called inside the debounce timer, so it always captures the latest state.
24
+ * If omitted, `store.getState().data` is used as-is.
25
+ */
26
+ serialize?: (currentData: Record<string, unknown>) => Record<string, unknown>;
27
+ /**
28
+ * Called when the estimated payload size exceeds `warnBytes` but is still below `maxBytes`.
29
+ * Use to show a warning in the UI.
30
+ */
31
+ onSizeWarning?: (estimatedBytes: number) => void;
32
+ /**
33
+ * Called when the estimated payload size exceeds `maxBytes`.
34
+ * The push is blocked. Use to alert the user that data needs to be pruned.
35
+ * If omitted, a console error is printed.
36
+ */
37
+ onSizeExceeded?: (estimatedBytes: number) => void;
38
+ }
39
+ export interface DebouncedSync {
40
+ /**
41
+ * Schedule a push. If called again within `delayMs`, the timer resets.
42
+ * Safe to call on every domain store mutation.
43
+ */
44
+ notify: () => void;
45
+ /** Cancel any pending debounced push. Does not affect an already-in-flight push. */
46
+ cancel: () => void;
47
+ }
48
+ export interface DebouncedPushOptions {
49
+ /**
50
+ * How long to wait after the last `notify()` call before pushing (default: 2000 ms).
51
+ */
52
+ delayMs?: number;
53
+ /**
54
+ * Required: provides the document to push when the debounce timer fires.
55
+ * Called inside the timer so it always captures the latest state.
56
+ */
57
+ serialize: () => Record<string, unknown>;
58
+ /**
59
+ * Emit a warning when the estimated encrypted payload exceeds this byte count (default: 900 KB).
60
+ * Set to `Infinity` to disable.
61
+ */
62
+ warnBytes?: number;
63
+ /**
64
+ * Block the push when the estimated encrypted payload exceeds this byte count (default: 1 MB).
65
+ * Set to `Infinity` to disable.
66
+ */
67
+ maxBytes?: number;
68
+ /**
69
+ * Called when the estimated payload size exceeds `warnBytes` but is below `maxBytes`.
70
+ */
71
+ onSizeWarning?: (estimatedBytes: number) => void;
72
+ /**
73
+ * Called when the estimated payload size exceeds `maxBytes`. The push is blocked.
74
+ * If omitted, a console error is printed.
75
+ */
76
+ onSizeExceeded?: (estimatedBytes: number) => void;
77
+ /**
78
+ * Called when `syncManager.push()` throws. Default: `console.warn`.
79
+ */
80
+ onError?: (err: unknown) => void;
81
+ }
82
+ export interface DebouncedPush {
83
+ /**
84
+ * Schedule a push. If called again within `delayMs`, the timer resets.
85
+ */
86
+ notify: () => void;
87
+ /** Cancel any pending debounced push. Does not affect an already-in-flight push. */
88
+ cancel: () => void;
89
+ }
90
+ /**
91
+ * Creates a debounced push helper that coalesces rapid mutations into a single sync.
92
+ *
93
+ * Designed to be called on every domain store mutation (e.g., every keystroke).
94
+ * The push is delayed by `delayMs` after the **last** call, so typing quickly
95
+ * results in one push, not one per character.
96
+ *
97
+ * Also estimates the encrypted payload size before pushing and warns / blocks
98
+ * if it approaches the server's body size limit.
99
+ *
100
+ * ```ts
101
+ * const { notify } = createDebouncedSync(starfishStore, {
102
+ * serialize: () => ({ tasks: taskStore.getState().tasks }),
103
+ * })
104
+ *
105
+ * // Call on every domain store mutation:
106
+ * taskStore.subscribe(() => notify())
107
+ * ```
108
+ */
109
+ export declare function createDebouncedSync(store: StoreApi<StarfishStore>, options?: DebouncedSyncOptions): DebouncedSync;
110
+ /**
111
+ * Creates a debounced push helper that calls `syncManager.push()` directly,
112
+ * without requiring a Zustand store.
113
+ *
114
+ * Use this for one-way publishing workflows: public pages, derived snapshots,
115
+ * or any case where you want to push data without a full `createStarfishStore` setup.
116
+ *
117
+ * ```ts
118
+ * const syncManager = new SyncManager({ client, pullPath, pushPath })
119
+ *
120
+ * const { notify, cancel } = createDebouncedPush(syncManager, {
121
+ * serialize: () => buildPublicPageDocument(),
122
+ * })
123
+ *
124
+ * // Push after every relevant store mutation:
125
+ * planningStore.subscribe(() => notify())
126
+ *
127
+ * // Clean up on teardown:
128
+ * cancel()
129
+ * ```
130
+ */
131
+ export declare function createDebouncedPush(syncManager: SyncManager, options: DebouncedPushOptions): DebouncedPush;
@@ -0,0 +1,120 @@
1
+ // ── Implementation ────────────────────────────────────────────────────────────
2
+ const DEFAULT_DELAY_MS = 2000;
3
+ const DEFAULT_WARN_BYTES = 900 * 1024; // 900 KB
4
+ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB
5
+ /** Returns true if the push should be blocked. */
6
+ function checkPayloadSize(doc, opts) {
7
+ // Estimate encrypted payload size. AES-GCM output is similar to input size;
8
+ // base64 encoding adds ~33% overhead, plus a small IV/tag overhead.
9
+ const estimatedBytes = Math.ceil(JSON.stringify(doc).length * 1.34);
10
+ if (estimatedBytes > opts.maxBytes) {
11
+ if (opts.onSizeExceeded) {
12
+ opts.onSizeExceeded(estimatedBytes);
13
+ }
14
+ else {
15
+ console.error(`[starfish] Push blocked: estimated payload ${(estimatedBytes / 1024).toFixed(0)} KB ` +
16
+ `exceeds limit of ${(opts.maxBytes / 1024).toFixed(0)} KB. Prune your data before syncing.`);
17
+ }
18
+ return true;
19
+ }
20
+ if (estimatedBytes > opts.warnBytes) {
21
+ if (opts.onSizeWarning) {
22
+ opts.onSizeWarning(estimatedBytes);
23
+ }
24
+ else {
25
+ console.warn(`[starfish] Payload approaching limit: estimated ${(estimatedBytes / 1024).toFixed(0)} KB ` +
26
+ `(warn threshold: ${(opts.warnBytes / 1024).toFixed(0)} KB).`);
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ /**
32
+ * Creates a debounced push helper that coalesces rapid mutations into a single sync.
33
+ *
34
+ * Designed to be called on every domain store mutation (e.g., every keystroke).
35
+ * The push is delayed by `delayMs` after the **last** call, so typing quickly
36
+ * results in one push, not one per character.
37
+ *
38
+ * Also estimates the encrypted payload size before pushing and warns / blocks
39
+ * if it approaches the server's body size limit.
40
+ *
41
+ * ```ts
42
+ * const { notify } = createDebouncedSync(starfishStore, {
43
+ * serialize: () => ({ tasks: taskStore.getState().tasks }),
44
+ * })
45
+ *
46
+ * // Call on every domain store mutation:
47
+ * taskStore.subscribe(() => notify())
48
+ * ```
49
+ */
50
+ export function createDebouncedSync(store, options = {}) {
51
+ const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, } = options;
52
+ let timer = null;
53
+ function cancel() {
54
+ if (timer !== null) {
55
+ clearTimeout(timer);
56
+ timer = null;
57
+ }
58
+ }
59
+ function notify() {
60
+ cancel();
61
+ timer = setTimeout(() => {
62
+ timer = null;
63
+ const current = store.getState().data;
64
+ const doc = serialize ? serialize(current) : current;
65
+ if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
66
+ return;
67
+ store.getState().set(() => doc);
68
+ }, delayMs);
69
+ }
70
+ return { notify, cancel };
71
+ }
72
+ /**
73
+ * Creates a debounced push helper that calls `syncManager.push()` directly,
74
+ * without requiring a Zustand store.
75
+ *
76
+ * Use this for one-way publishing workflows: public pages, derived snapshots,
77
+ * or any case where you want to push data without a full `createStarfishStore` setup.
78
+ *
79
+ * ```ts
80
+ * const syncManager = new SyncManager({ client, pullPath, pushPath })
81
+ *
82
+ * const { notify, cancel } = createDebouncedPush(syncManager, {
83
+ * serialize: () => buildPublicPageDocument(),
84
+ * })
85
+ *
86
+ * // Push after every relevant store mutation:
87
+ * planningStore.subscribe(() => notify())
88
+ *
89
+ * // Clean up on teardown:
90
+ * cancel()
91
+ * ```
92
+ */
93
+ export function createDebouncedPush(syncManager, options) {
94
+ const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, onError, } = options;
95
+ let timer = null;
96
+ function cancel() {
97
+ if (timer !== null) {
98
+ clearTimeout(timer);
99
+ timer = null;
100
+ }
101
+ }
102
+ function notify() {
103
+ cancel();
104
+ timer = setTimeout(() => {
105
+ timer = null;
106
+ const doc = serialize();
107
+ if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
108
+ return;
109
+ syncManager.push(doc).catch((err) => {
110
+ if (onError) {
111
+ onError(err);
112
+ }
113
+ else {
114
+ console.warn("[starfish] Push failed:", err);
115
+ }
116
+ });
117
+ }, delayMs);
118
+ }
119
+ return { notify, cancel };
120
+ }
@@ -0,0 +1,72 @@
1
+ declare const WORDLIST: string[];
2
+ /** Credentials derived from a passphrase. Pass directly to SyncManager / StarfishClient. */
3
+ export interface DerivedCredentials {
4
+ /** Hex-encoded auth token. Use as `Bearer ${authToken}` in request headers. */
5
+ authToken: string;
6
+ /**
7
+ * Short identifier derived from the auth token (first 16 hex chars = 8 bytes).
8
+ * Used as the user/namespace segment in collection paths.
9
+ */
10
+ userId: string;
11
+ /**
12
+ * Hex-encoded key suitable as `encryptionSecret` for SyncManager.
13
+ * Combined with `encryptionSalt` to derive the AES-256-GCM key via HKDF.
14
+ */
15
+ encryptionSecret: string;
16
+ /**
17
+ * Value suitable as `encryptionSalt` for SyncManager. Equals `userId`.
18
+ * Using a per-identity salt ensures that even if two users share a passphrase,
19
+ * their encryption keys are different.
20
+ */
21
+ encryptionSalt: string;
22
+ }
23
+ /**
24
+ * Generates a cryptographically random passphrase from the built-in 256-word list.
25
+ *
26
+ * Each word represents one byte of entropy (256 words = one byte per word, zero modulo bias).
27
+ * A 12-word passphrase gives 96 bits of entropy — stronger than a random UUID.
28
+ *
29
+ * @param wordCount Number of words (default: 12).
30
+ * @param wordlist Custom word list (must have exactly 256 entries).
31
+ */
32
+ export declare function generatePassphrase(wordCount?: number, wordlist?: string[]): string;
33
+ /**
34
+ * Derives auth credentials from a passphrase.
35
+ *
36
+ * All derivations are deterministic — the same passphrase always produces the same credentials.
37
+ * Sharing the passphrase grants access on any device.
38
+ *
39
+ * The returned values map directly to Starfish options:
40
+ * ```ts
41
+ * const creds = await deriveCredentials(passphrase)
42
+ *
43
+ * const client = new StarfishClient({
44
+ * baseUrl: serverUrl,
45
+ * auth: () => ({ Authorization: `Bearer ${creds.authToken}` }),
46
+ * })
47
+ * const syncManager = new SyncManager({
48
+ * client,
49
+ * pullPath: `/pull/${creds.userId}/wedding`,
50
+ * pushPath: `/push/${creds.userId}/wedding`,
51
+ * encryptionSecret: creds.encryptionSecret,
52
+ * encryptionSalt: creds.encryptionSalt,
53
+ * })
54
+ * ```
55
+ */
56
+ export declare function deriveCredentials(passphrase: string): Promise<DerivedCredentials>;
57
+ /**
58
+ * Encodes an invite payload as a URL-safe token and appends it as `?t=<token>`.
59
+ *
60
+ * ```ts
61
+ * const url = buildInviteUrl("myapp://join", { name: "Alice & Bob", p: passphrase })
62
+ * // → "myapp://join?t=eyJuYW1lIjoiQWxpY2UgJiBCb2IifQ"
63
+ * ```
64
+ */
65
+ export declare function buildInviteUrl(baseUrl: string, payload: Record<string, unknown>): string;
66
+ /**
67
+ * Decodes an invite URL produced by `buildInviteUrl`.
68
+ *
69
+ * Returns the decoded payload, or `null` if the URL is missing or malformed.
70
+ */
71
+ export declare function parseInviteUrl(url: string): Record<string, unknown> | null;
72
+ export { WORDLIST as DEFAULT_WORDLIST };
@@ -0,0 +1,161 @@
1
+ import { getCrypto, getBase64 } from "@drakkar.software/starfish-protocol";
2
+ // ── Word list ─────────────────────────────────────────────────────────────────
3
+ // 256 common English words, one per index (0-255). One byte of entropy per word.
4
+ // 12 words = 96 bits of entropy (stronger than a random UUID).
5
+ const WORDLIST = [
6
+ "able", "acid", "aged", "also", "area", "army", "away", "back",
7
+ "ball", "band", "bank", "base", "bath", "bean", "bear", "beat",
8
+ "bell", "best", "bird", "blue", "boat", "bold", "bolt", "bone",
9
+ "born", "bowl", "burn", "calm", "call", "camp", "card", "care",
10
+ "cash", "cast", "cave", "city", "clam", "clay", "clip", "coal",
11
+ "coat", "coin", "cold", "cook", "cool", "corn", "cost", "cozy",
12
+ "dark", "data", "dawn", "dead", "deal", "deck", "deep", "dew",
13
+ "dish", "dome", "door", "down", "draw", "drop", "drum", "dusk",
14
+ "dust", "each", "earn", "east", "edge", "even", "ever", "face",
15
+ "fact", "fair", "fall", "fame", "farm", "fast", "felt", "file",
16
+ "fill", "fire", "fish", "fist", "flag", "flat", "flew", "flow",
17
+ "foam", "fold", "fond", "food", "foot", "form", "frog", "fuel",
18
+ "full", "gain", "game", "gate", "gave", "gaze", "gift", "glad",
19
+ "glow", "glue", "goal", "good", "grab", "gray", "grip", "grow",
20
+ "gulf", "gust", "half", "hall", "hand", "hard", "harm", "have",
21
+ "hawk", "head", "heal", "heap", "heat", "held", "helm", "help",
22
+ "herb", "here", "hero", "high", "hill", "hint", "hold", "hole",
23
+ "home", "hope", "horn", "hour", "huge", "hunt", "idea", "inch",
24
+ "into", "iris", "isle", "jade", "jail", "join", "jump", "just",
25
+ "keep", "kind", "king", "knot", "know", "lack", "lake", "land",
26
+ "lane", "last", "late", "lawn", "lead", "leaf", "lean", "leap",
27
+ "left", "lend", "less", "life", "lift", "like", "lime", "line",
28
+ "lion", "list", "live", "load", "lock", "loft", "long", "look",
29
+ "loop", "loud", "love", "luck", "lung", "made", "main", "mark",
30
+ "mast", "math", "maze", "meal", "meet", "melt", "mild", "mind",
31
+ "mint", "mist", "mode", "moon", "more", "most", "move", "much",
32
+ "must", "name", "near", "neck", "need", "next", "nice", "nine",
33
+ "none", "noon", "note", "noun", "oath", "once", "open", "oval",
34
+ "over", "pack", "page", "paid", "pain", "pale", "palm", "park",
35
+ "part", "path", "pave", "peak", "pier", "pile", "pine", "pipe",
36
+ "plan", "plum", "poem", "pole", "pool", "port", "pose", "post",
37
+ "pray", "prey", "pull", "pure", "push", "quit", "race", "rack",
38
+ ];
39
+ // ── Helpers ───────────────────────────────────────────────────────────────────
40
+ function bytesToHex(bytes) {
41
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
42
+ }
43
+ async function sha256Hex(input) {
44
+ const c = getCrypto();
45
+ const encoded = new TextEncoder().encode(input);
46
+ const hash = await c.subtle.digest("SHA-256", encoded);
47
+ return bytesToHex(new Uint8Array(hash));
48
+ }
49
+ // URL-safe base64 (RFC 4648 §5): replaces + → -, / → _, strips trailing =
50
+ function base64UrlEncode(data) {
51
+ const b64 = getBase64();
52
+ return b64
53
+ .encode(data)
54
+ .replace(/\+/g, "-")
55
+ .replace(/\//g, "_")
56
+ .replace(/=+$/, "");
57
+ }
58
+ function base64UrlDecode(encoded) {
59
+ const b64 = getBase64();
60
+ // Restore standard base64 padding
61
+ const padded = encoded.replace(/-/g, "+").replace(/_/g, "/");
62
+ const rem = padded.length % 4;
63
+ const padded2 = rem === 0 ? padded : padded + "=".repeat(4 - rem);
64
+ return b64.decode(padded2);
65
+ }
66
+ // ── Public API ────────────────────────────────────────────────────────────────
67
+ /**
68
+ * Generates a cryptographically random passphrase from the built-in 256-word list.
69
+ *
70
+ * Each word represents one byte of entropy (256 words = one byte per word, zero modulo bias).
71
+ * A 12-word passphrase gives 96 bits of entropy — stronger than a random UUID.
72
+ *
73
+ * @param wordCount Number of words (default: 12).
74
+ * @param wordlist Custom word list (must have exactly 256 entries).
75
+ */
76
+ export function generatePassphrase(wordCount = 12, wordlist = WORDLIST) {
77
+ if (wordlist.length !== 256) {
78
+ throw new Error(`Word list must have exactly 256 entries, got ${wordlist.length}`);
79
+ }
80
+ const c = getCrypto();
81
+ const bytes = c.getRandomValues(new Uint8Array(wordCount));
82
+ return Array.from(bytes, (b) => wordlist[b]).join(" ");
83
+ }
84
+ /**
85
+ * Derives auth credentials from a passphrase.
86
+ *
87
+ * All derivations are deterministic — the same passphrase always produces the same credentials.
88
+ * Sharing the passphrase grants access on any device.
89
+ *
90
+ * The returned values map directly to Starfish options:
91
+ * ```ts
92
+ * const creds = await deriveCredentials(passphrase)
93
+ *
94
+ * const client = new StarfishClient({
95
+ * baseUrl: serverUrl,
96
+ * auth: () => ({ Authorization: `Bearer ${creds.authToken}` }),
97
+ * })
98
+ * const syncManager = new SyncManager({
99
+ * client,
100
+ * pullPath: `/pull/${creds.userId}/wedding`,
101
+ * pushPath: `/push/${creds.userId}/wedding`,
102
+ * encryptionSecret: creds.encryptionSecret,
103
+ * encryptionSalt: creds.encryptionSalt,
104
+ * })
105
+ * ```
106
+ */
107
+ export async function deriveCredentials(passphrase) {
108
+ if (!passphrase.trim())
109
+ throw new Error("Passphrase must not be empty");
110
+ // authToken = SHA-256(passphrase) — used as Bearer token
111
+ const authToken = await sha256Hex(passphrase);
112
+ // userId = first 16 hex chars of authToken (8 bytes)
113
+ const userId = authToken.slice(0, 16);
114
+ // encryptionSecret = SHA-256(passphrase + ":" + userId)
115
+ // Domain separation from authToken ensures they cannot be recovered from each other.
116
+ const encryptionSecret = await sha256Hex(`${passphrase}:${userId}`);
117
+ return {
118
+ authToken,
119
+ userId,
120
+ encryptionSecret,
121
+ encryptionSalt: userId,
122
+ };
123
+ }
124
+ /**
125
+ * Encodes an invite payload as a URL-safe token and appends it as `?t=<token>`.
126
+ *
127
+ * ```ts
128
+ * const url = buildInviteUrl("myapp://join", { name: "Alice & Bob", p: passphrase })
129
+ * // → "myapp://join?t=eyJuYW1lIjoiQWxpY2UgJiBCb2IifQ"
130
+ * ```
131
+ */
132
+ export function buildInviteUrl(baseUrl, payload) {
133
+ const json = JSON.stringify(payload);
134
+ const bytes = new TextEncoder().encode(json);
135
+ const token = base64UrlEncode(bytes);
136
+ const separator = baseUrl.includes("?") ? "&" : "?";
137
+ return `${baseUrl}${separator}t=${token}`;
138
+ }
139
+ /**
140
+ * Decodes an invite URL produced by `buildInviteUrl`.
141
+ *
142
+ * Returns the decoded payload, or `null` if the URL is missing or malformed.
143
+ */
144
+ export function parseInviteUrl(url) {
145
+ try {
146
+ const tokenMatch = url.match(/[?&]t=([^&]+)/);
147
+ if (!tokenMatch?.[1])
148
+ return null;
149
+ const bytes = base64UrlDecode(tokenMatch[1]);
150
+ const json = new TextDecoder().decode(bytes);
151
+ const parsed = JSON.parse(json);
152
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
153
+ return null;
154
+ return parsed;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ // Export word list for consumers that want to provide localized alternatives
161
+ export { WORDLIST as DEFAULT_WORDLIST };
package/dist/index.d.ts CHANGED
@@ -34,3 +34,9 @@ export type { BackgroundSyncOptions } from "./background-sync.js";
34
34
  export { isServiceWorkerSupported, registerServiceWorker, unregisterServiceWorkers } from "./service-worker.js";
35
35
  export type { ServiceWorkerOptions } from "./service-worker.js";
36
36
  export { createSuspenseResource } from "./bindings/suspense.js";
37
+ export { createDebouncedSync, createDebouncedPush } from "./debounced-sync.js";
38
+ export type { DebouncedSyncOptions, DebouncedSync, DebouncedPushOptions, DebouncedPush } from "./debounced-sync.js";
39
+ export { createMobileLifecycle } from "./mobile-lifecycle.js";
40
+ export type { AppStateModule, NetInfoModule, MobileLifecycleDeps, MobileLifecycleOptions } from "./mobile-lifecycle.js";
41
+ export { createMultiStoreSync } from "./multi-store.js";
42
+ export type { StoreSlice, BackupDocument, MultiStoreMigrationFn, MultiStoreSyncOptions, MultiStoreSync, } from "./multi-store.js";
package/dist/index.js CHANGED
@@ -17,3 +17,6 @@ export { exportData, importData, exportToBlob } from "./export.js";
17
17
  export { isBackgroundSyncSupported, registerBackgroundSync } from "./background-sync.js";
18
18
  export { isServiceWorkerSupported, registerServiceWorker, unregisterServiceWorkers } from "./service-worker.js";
19
19
  export { createSuspenseResource } from "./bindings/suspense.js";
20
+ export { createDebouncedSync, createDebouncedPush } from "./debounced-sync.js";
21
+ export { createMobileLifecycle } from "./mobile-lifecycle.js";
22
+ export { createMultiStoreSync } from "./multi-store.js";
@@ -0,0 +1,71 @@
1
+ import type { StoreApi } from "zustand/vanilla";
2
+ import type { StarfishStore } from "./bindings/zustand.js";
3
+ /**
4
+ * Minimal interface matching React Native's `AppState` module.
5
+ * Pass `AppState` from `react-native` directly.
6
+ */
7
+ export interface AppStateModule {
8
+ addEventListener: (type: "change", listener: (state: string) => void) => {
9
+ remove: () => void;
10
+ };
11
+ }
12
+ /**
13
+ * Minimal interface matching `@react-native-community/netinfo`'s default export.
14
+ * Pass `NetInfo` from `@react-native-community/netinfo` directly.
15
+ */
16
+ export interface NetInfoModule {
17
+ addEventListener: (listener: (state: {
18
+ isConnected: boolean | null;
19
+ }) => void) => () => void;
20
+ }
21
+ export interface MobileLifecycleDeps {
22
+ /** React Native `AppState` module. */
23
+ appState: AppStateModule;
24
+ /**
25
+ * Optional: NetInfo module from `@react-native-community/netinfo`.
26
+ * When provided, connectivity changes are forwarded to `store.getState().setOnline()`.
27
+ */
28
+ netInfo?: NetInfoModule;
29
+ }
30
+ export interface MobileLifecycleOptions {
31
+ /**
32
+ * Pull remote changes when the app returns to the foreground.
33
+ * Only pulls if the store is online and not already syncing.
34
+ * Default: `true`.
35
+ */
36
+ pullOnForeground?: boolean;
37
+ /**
38
+ * Flush dirty data when the app transitions to the background.
39
+ * Only flushes if the store has unsaved changes.
40
+ * Default: `true`.
41
+ */
42
+ flushOnBackground?: boolean;
43
+ }
44
+ /**
45
+ * Wires React Native app lifecycle events to a Starfish store.
46
+ *
47
+ * - **Background**: flushes pending changes before the OS suspends the app.
48
+ * - **Foreground**: pulls remote changes when the user returns to the app.
49
+ * - **NetInfo**: forwards connectivity changes to `store.getState().setOnline()`.
50
+ *
51
+ * Uses dependency injection so no `react-native` or `netinfo` imports are needed
52
+ * in this package. Pass the modules directly:
53
+ *
54
+ * ```ts
55
+ * import { AppState } from "react-native"
56
+ * import NetInfo from "@react-native-community/netinfo"
57
+ * import { createMobileLifecycle } from "@drakkar.software/starfish-client"
58
+ *
59
+ * // Call once, after the store is created:
60
+ * const cleanup = createMobileLifecycle(
61
+ * store,
62
+ * { appState: AppState, netInfo: NetInfo },
63
+ * )
64
+ *
65
+ * // In a React component (e.g. root layout):
66
+ * useEffect(() => cleanup, [])
67
+ * ```
68
+ *
69
+ * @returns A cleanup function that removes all event listeners.
70
+ */
71
+ export declare function createMobileLifecycle(store: StoreApi<StarfishStore>, deps: MobileLifecycleDeps, options?: MobileLifecycleOptions): () => void;
@@ -0,0 +1,55 @@
1
+ // ── Implementation ────────────────────────────────────────────────────────────
2
+ /**
3
+ * Wires React Native app lifecycle events to a Starfish store.
4
+ *
5
+ * - **Background**: flushes pending changes before the OS suspends the app.
6
+ * - **Foreground**: pulls remote changes when the user returns to the app.
7
+ * - **NetInfo**: forwards connectivity changes to `store.getState().setOnline()`.
8
+ *
9
+ * Uses dependency injection so no `react-native` or `netinfo` imports are needed
10
+ * in this package. Pass the modules directly:
11
+ *
12
+ * ```ts
13
+ * import { AppState } from "react-native"
14
+ * import NetInfo from "@react-native-community/netinfo"
15
+ * import { createMobileLifecycle } from "@drakkar.software/starfish-client"
16
+ *
17
+ * // Call once, after the store is created:
18
+ * const cleanup = createMobileLifecycle(
19
+ * store,
20
+ * { appState: AppState, netInfo: NetInfo },
21
+ * )
22
+ *
23
+ * // In a React component (e.g. root layout):
24
+ * useEffect(() => cleanup, [])
25
+ * ```
26
+ *
27
+ * @returns A cleanup function that removes all event listeners.
28
+ */
29
+ export function createMobileLifecycle(store, deps, options = {}) {
30
+ const { pullOnForeground = true, flushOnBackground = true } = options;
31
+ const appSub = deps.appState.addEventListener("change", (appState) => {
32
+ if (appState === "background" && flushOnBackground) {
33
+ if (store.getState().dirty) {
34
+ store.getState().flush().catch(() => { });
35
+ }
36
+ }
37
+ else if (appState === "active" && pullOnForeground) {
38
+ const { online, syncing } = store.getState();
39
+ if (online && !syncing) {
40
+ store.getState().pull().catch(() => { });
41
+ }
42
+ }
43
+ // "inactive" (iOS transition) and other states are intentionally ignored
44
+ });
45
+ let netUnsub = null;
46
+ if (deps.netInfo) {
47
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
48
+ store.getState().setOnline(!!isConnected);
49
+ });
50
+ }
51
+ return () => {
52
+ appSub.remove();
53
+ netUnsub?.();
54
+ };
55
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Serializer/deserializer pair for one slice of application state.
3
+ *
4
+ * `serialize` snapshots the current state into a plain object.
5
+ * `restore` applies a snapshot (potentially from a different app version after migration).
6
+ */
7
+ export interface StoreSlice<T = unknown> {
8
+ /**
9
+ * Snapshot the current state of this slice into a serializable value.
10
+ * Called during `serialize()`.
11
+ */
12
+ serialize: () => T;
13
+ /**
14
+ * Apply a snapshot to this slice.
15
+ * Called during `restore()` — data may be from an older schema version after migration.
16
+ */
17
+ restore: (data: T) => void;
18
+ }
19
+ /**
20
+ * A versioned backup document produced by `MultiStoreSync.serialize()`.
21
+ * Safe to pass to `store.set()` as the Starfish sync document.
22
+ */
23
+ export interface BackupDocument<T = Record<string, unknown>> {
24
+ /** Schema version declared in `createMultiStoreSync`. */
25
+ version: number;
26
+ /** Unix timestamp (ms) when this backup was created. */
27
+ timestamp: number;
28
+ /** Serialized slice data, keyed by slice name. */
29
+ data: T;
30
+ }
31
+ /**
32
+ * A migration function that transforms data from one version to the next.
33
+ * Receives the full `data` object and must return an updated `data` object.
34
+ * Only the `data` field is passed; `version` and `timestamp` are managed automatically.
35
+ */
36
+ export type MultiStoreMigrationFn = (data: Record<string, unknown>) => Record<string, unknown>;
37
+ export interface MultiStoreSyncOptions<T extends Record<string, unknown>> {
38
+ /**
39
+ * Named slices to include in the backup document.
40
+ * Each slice provides `serialize()` and `restore()` methods.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * slices: {
45
+ * tasks: {
46
+ * serialize: () => taskStore.getState().tasks,
47
+ * restore: (data) => taskStore.setState({ tasks: data }),
48
+ * },
49
+ * settings: {
50
+ * serialize: () => settingsStore.getState().settings,
51
+ * restore: (data) => settingsStore.setState({ settings: data }),
52
+ * },
53
+ * }
54
+ * ```
55
+ */
56
+ slices: {
57
+ [K in keyof T]: StoreSlice<T[K]>;
58
+ };
59
+ /**
60
+ * Current schema version. Increment when slices are added, renamed, or their shape changes.
61
+ * Used to detect forward-incompatible documents from future app versions.
62
+ */
63
+ version: number;
64
+ /**
65
+ * Optional migration chain. Key is the version number that produced the data;
66
+ * value is a function that upgrades it to the next version.
67
+ *
68
+ * Migrations run sequentially from the document version up to the current version.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * migrations: {
73
+ * 1: (data) => ({ ...data, settings: { ...data.settings, theme: "light" } }),
74
+ * 2: (data) => ({ ...data, tasks: data.todos, todos: undefined }),
75
+ * }
76
+ * ```
77
+ */
78
+ migrations?: Record<number, MultiStoreMigrationFn>;
79
+ }
80
+ /**
81
+ * Returned by `createMultiStoreSync`. Serialize and restore coordinated multi-store state.
82
+ */
83
+ export interface MultiStoreSync<T extends Record<string, unknown>> {
84
+ /**
85
+ * Snapshot all slices into a `BackupDocument`.
86
+ * Pass the result to `starfishStore.getState().set(() => multiSync.serialize())`.
87
+ */
88
+ serialize: () => BackupDocument<T>;
89
+ /**
90
+ * Apply a `BackupDocument` to all slices, running migrations as needed.
91
+ *
92
+ * Throws if the document version is newer than the current version (forward-incompatible).
93
+ * Silently migrates older documents.
94
+ */
95
+ restore: (doc: BackupDocument) => void;
96
+ /** Current schema version as declared in options. */
97
+ readonly version: number;
98
+ }
99
+ /**
100
+ * Creates a multi-store sync coordinator.
101
+ *
102
+ * Collects multiple application stores into a single Starfish sync document,
103
+ * with versioned schema migrations for backward compatibility.
104
+ *
105
+ * ```ts
106
+ * const multiSync = createMultiStoreSync({
107
+ * slices: {
108
+ * tasks: {
109
+ * serialize: () => taskStore.getState().tasks,
110
+ * restore: (tasks) => taskStore.setState({ tasks }),
111
+ * },
112
+ * settings: {
113
+ * serialize: () => settingsStore.getState().settings,
114
+ * restore: (settings) => settingsStore.setState({ settings }),
115
+ * },
116
+ * },
117
+ * version: 2,
118
+ * migrations: {
119
+ * // data from version 1 → upgrade to version 2
120
+ * 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
121
+ * },
122
+ * })
123
+ *
124
+ * // Push:
125
+ * starfishStore.getState().set(() => multiSync.serialize())
126
+ *
127
+ * // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
128
+ * createStarfishStore({
129
+ * name: "app",
130
+ * syncManager,
131
+ * onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
132
+ * })
133
+ * ```
134
+ */
135
+ export declare function createMultiStoreSync<T extends Record<string, unknown>>(options: MultiStoreSyncOptions<T>): MultiStoreSync<T>;
@@ -0,0 +1,92 @@
1
+ // ── Types ─────────────────────────────────────────────────────────────────────
2
+ // ── Implementation ────────────────────────────────────────────────────────────
3
+ /**
4
+ * Creates a multi-store sync coordinator.
5
+ *
6
+ * Collects multiple application stores into a single Starfish sync document,
7
+ * with versioned schema migrations for backward compatibility.
8
+ *
9
+ * ```ts
10
+ * const multiSync = createMultiStoreSync({
11
+ * slices: {
12
+ * tasks: {
13
+ * serialize: () => taskStore.getState().tasks,
14
+ * restore: (tasks) => taskStore.setState({ tasks }),
15
+ * },
16
+ * settings: {
17
+ * serialize: () => settingsStore.getState().settings,
18
+ * restore: (settings) => settingsStore.setState({ settings }),
19
+ * },
20
+ * },
21
+ * version: 2,
22
+ * migrations: {
23
+ * // data from version 1 → upgrade to version 2
24
+ * 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
25
+ * },
26
+ * })
27
+ *
28
+ * // Push:
29
+ * starfishStore.getState().set(() => multiSync.serialize())
30
+ *
31
+ * // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
32
+ * createStarfishStore({
33
+ * name: "app",
34
+ * syncManager,
35
+ * onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
36
+ * })
37
+ * ```
38
+ */
39
+ export function createMultiStoreSync(options) {
40
+ const { slices, version, migrations = {} } = options;
41
+ // Validate migration chain at construction time (fail fast)
42
+ for (const fromVersion of Object.keys(migrations)) {
43
+ const v = Number(fromVersion);
44
+ if (isNaN(v) || v < 1) {
45
+ throw new Error(`Migration key must be a positive integer, got: "${fromVersion}"`);
46
+ }
47
+ }
48
+ function serialize() {
49
+ const data = {};
50
+ for (const key of Object.keys(slices)) {
51
+ data[key] = slices[key].serialize();
52
+ }
53
+ return { version, timestamp: Date.now(), data };
54
+ }
55
+ function restore(doc) {
56
+ if (typeof doc !== "object" || doc === null) {
57
+ throw new Error("restore: expected a BackupDocument object");
58
+ }
59
+ const docVersion = doc.version ?? 1;
60
+ if (typeof docVersion !== "number" || !Number.isInteger(docVersion) || docVersion < 1) {
61
+ throw new Error(`restore: invalid document version: ${String(doc.version)}`);
62
+ }
63
+ if (docVersion > version) {
64
+ throw new Error(`restore: document version ${docVersion} is newer than current version ${version}. ` +
65
+ `Update the app to restore this backup.`);
66
+ }
67
+ // Run migrations sequentially from docVersion up to current version
68
+ let data = typeof doc.data === "object" && doc.data !== null
69
+ ? { ...doc.data }
70
+ : {};
71
+ for (let v = docVersion; v < version; v++) {
72
+ const migration = migrations[v];
73
+ if (!migration)
74
+ continue;
75
+ try {
76
+ data = migration(data);
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ throw new Error(`restore: migration from version ${v} to ${v + 1} failed: ${msg}`);
81
+ }
82
+ }
83
+ // Restore each slice
84
+ for (const key of Object.keys(slices)) {
85
+ const sliceData = data[key];
86
+ if (sliceData !== undefined) {
87
+ slices[key].restore(sliceData);
88
+ }
89
+ }
90
+ }
91
+ return { serialize, restore, version };
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/starfish-client",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Drakkar-Software/starfish.git",
@@ -37,6 +37,10 @@
37
37
  "./testing": {
38
38
  "types": "./dist/testing.d.ts",
39
39
  "import": "./dist/testing.js"
40
+ },
41
+ "./identity": {
42
+ "types": "./dist/identity.d.ts",
43
+ "import": "./dist/identity.js"
40
44
  }
41
45
  },
42
46
  "peerDependencies": {