@drakkar.software/starfish-client 1.6.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.
- package/dist/debounced-sync.d.ts +65 -0
- package/dist/debounced-sync.js +74 -19
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/mobile-lifecycle.d.ts +71 -0
- package/dist/mobile-lifecycle.js +55 -0
- package/package.json +1 -1
package/dist/debounced-sync.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StoreApi } from "zustand/vanilla";
|
|
2
2
|
import type { StarfishStore } from "./bindings/zustand.js";
|
|
3
|
+
import type { SyncManager } from "./sync.js";
|
|
3
4
|
export interface DebouncedSyncOptions {
|
|
4
5
|
/**
|
|
5
6
|
* How long to wait after the last `notify()` call before pushing (default: 2000 ms).
|
|
@@ -44,6 +45,48 @@ export interface DebouncedSync {
|
|
|
44
45
|
/** Cancel any pending debounced push. Does not affect an already-in-flight push. */
|
|
45
46
|
cancel: () => void;
|
|
46
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
|
+
}
|
|
47
90
|
/**
|
|
48
91
|
* Creates a debounced push helper that coalesces rapid mutations into a single sync.
|
|
49
92
|
*
|
|
@@ -64,3 +107,25 @@ export interface DebouncedSync {
|
|
|
64
107
|
* ```
|
|
65
108
|
*/
|
|
66
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;
|
package/dist/debounced-sync.js
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
const DEFAULT_DELAY_MS = 2000;
|
|
3
3
|
const DEFAULT_WARN_BYTES = 900 * 1024; // 900 KB
|
|
4
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
|
+
}
|
|
5
31
|
/**
|
|
6
32
|
* Creates a debounced push helper that coalesces rapid mutations into a single sync.
|
|
7
33
|
*
|
|
@@ -36,29 +62,58 @@ export function createDebouncedSync(store, options = {}) {
|
|
|
36
62
|
timer = null;
|
|
37
63
|
const current = store.getState().data;
|
|
38
64
|
const doc = serialize ? serialize(current) : current;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 }))
|
|
50
108
|
return;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
onSizeWarning(estimatedBytes);
|
|
109
|
+
syncManager.push(doc).catch((err) => {
|
|
110
|
+
if (onError) {
|
|
111
|
+
onError(err);
|
|
55
112
|
}
|
|
56
113
|
else {
|
|
57
|
-
console.warn(
|
|
58
|
-
`(warn threshold: ${(warnBytes / 1024).toFixed(0)} KB).`);
|
|
114
|
+
console.warn("[starfish] Push failed:", err);
|
|
59
115
|
}
|
|
60
|
-
}
|
|
61
|
-
store.getState().set(() => doc);
|
|
116
|
+
});
|
|
62
117
|
}, delayMs);
|
|
63
118
|
}
|
|
64
119
|
return { notify, cancel };
|
package/dist/index.d.ts
CHANGED
|
@@ -34,7 +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 } from "./debounced-sync.js";
|
|
38
|
-
export type { DebouncedSyncOptions, DebouncedSync } from "./debounced-sync.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";
|
|
39
41
|
export { createMultiStoreSync } from "./multi-store.js";
|
|
40
42
|
export type { StoreSlice, BackupDocument, MultiStoreMigrationFn, MultiStoreSyncOptions, MultiStoreSync, } from "./multi-store.js";
|
package/dist/index.js
CHANGED
|
@@ -17,5 +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 } from "./debounced-sync.js";
|
|
20
|
+
export { createDebouncedSync, createDebouncedPush } from "./debounced-sync.js";
|
|
21
|
+
export { createMobileLifecycle } from "./mobile-lifecycle.js";
|
|
21
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
|
+
}
|