@drakkar.software/starfish-client 3.0.0-alpha.5 → 3.0.0-alpha.51

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 (47) hide show
  1. package/README.md +59 -0
  2. package/dist/append-log.d.ts +228 -0
  3. package/dist/append-log.js +267 -0
  4. package/dist/background-sync.js +29 -0
  5. package/dist/bindings/legend.d.ts +23 -0
  6. package/dist/bindings/legend.js +32 -0
  7. package/dist/bindings/legend.js.map +2 -2
  8. package/dist/bindings/suspense.js +49 -0
  9. package/dist/bindings/zustand.d.ts +167 -2
  10. package/dist/bindings/zustand.js +942 -82
  11. package/dist/bindings/zustand.js.map +4 -4
  12. package/dist/blob-seal.d.ts +123 -0
  13. package/dist/client.d.ts +270 -5
  14. package/dist/client.js +391 -0
  15. package/dist/config.d.ts +9 -0
  16. package/dist/config.js +18 -0
  17. package/dist/debounced-sync.js +120 -0
  18. package/dist/dedup.js +35 -0
  19. package/dist/events.d.ts +150 -0
  20. package/dist/events.js +116 -0
  21. package/dist/events.js.map +7 -0
  22. package/dist/export.js +115 -0
  23. package/dist/fetch.d.ts +40 -0
  24. package/dist/fetch.js +51 -14
  25. package/dist/fetch.js.map +2 -2
  26. package/dist/history.js +61 -0
  27. package/dist/index.d.ts +16 -7
  28. package/dist/index.js +1030 -94
  29. package/dist/index.js.map +4 -4
  30. package/dist/kv-cache.d.ts +63 -0
  31. package/dist/logger.d.ts +3 -0
  32. package/dist/logger.js +80 -0
  33. package/dist/migrate.js +38 -0
  34. package/dist/mobile-lifecycle.d.ts +28 -1
  35. package/dist/mobile-lifecycle.js +94 -0
  36. package/dist/multi-store.js +92 -0
  37. package/dist/mutate.d.ts +39 -0
  38. package/dist/polling.js +52 -0
  39. package/dist/resolvers.js +223 -0
  40. package/dist/service-worker.js +55 -0
  41. package/dist/storage/indexeddb.js +59 -0
  42. package/dist/sync.d.ts +83 -0
  43. package/dist/sync.js +181 -0
  44. package/dist/types.d.ts +106 -11
  45. package/dist/types.js +18 -0
  46. package/dist/validate.js +28 -0
  47. package/package.json +12 -3
@@ -0,0 +1,63 @@
1
+ /**
2
+ * KV-backed {@link PullCache} factory.
3
+ *
4
+ * {@link createKvPullCache} adapts any {@link AsyncStateStorage} (or any
5
+ * object with `getItem`/`setItem`) into a {@link PullCache} that the
6
+ * `StarfishClient` can use as its offline read-through cache.
7
+ *
8
+ * Why this matters: the client's `cache` option is ciphertext-at-rest by
9
+ * construction — it stores the raw sealed server response and only decrypts
10
+ * in memory on read. Backing the cache with the platform's own KV (AsyncStorage
11
+ * on React Native, IndexedDB via `createIndexedDBStorage`, a custom adapter)
12
+ * gives offline-first reads without exposing plaintext to the OS storage layer.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import AsyncStorage from "@react-native-async-storage/async-storage"
17
+ * import { StarfishClient } from "@drakkar.software/starfish-client"
18
+ * import { createKvPullCache } from "@drakkar.software/starfish-client"
19
+ *
20
+ * const client = new StarfishClient({
21
+ * baseUrl: "https://api.example.com",
22
+ * cache: createKvPullCache(AsyncStorage, { prefix: "sf:", maxAgeMs: 30 * 24 * 60 * 60 * 1000 }),
23
+ * })
24
+ * ```
25
+ */
26
+ import type { PullCache } from "./types.js";
27
+ /** A storage backend that `createKvPullCache` can wrap. */
28
+ export interface KvStore {
29
+ getItem(key: string): Promise<string | null> | string | null;
30
+ setItem(key: string, value: string): Promise<unknown> | unknown;
31
+ removeItem?: (key: string) => Promise<unknown> | unknown;
32
+ }
33
+ /** Options for {@link createKvPullCache}. */
34
+ export interface KvPullCacheOptions {
35
+ /**
36
+ * Key prefix for cache entries (default `"starfish.pullcache."`). Change
37
+ * when sharing a KV store with other data to avoid key collisions.
38
+ */
39
+ prefix?: string;
40
+ /**
41
+ * Maximum age in milliseconds for a cached snapshot. When set, an entry
42
+ * older than `maxAgeMs` is returned as `null` (a cache MISS) so the next
43
+ * pull goes to the network rather than serving arbitrarily stale data.
44
+ *
45
+ * Each entry stores a `_cachedAt` wall-clock timestamp; expiry is checked on
46
+ * every `get`. Omit (default) for entries that never expire — recommended
47
+ * for offline-first apps where any last-synced data beats none.
48
+ */
49
+ maxAgeMs?: number;
50
+ }
51
+ /**
52
+ * Adapt a KV store into a {@link PullCache} for `StarfishClient`.
53
+ *
54
+ * The adapter serialises the cached pull payload (itself a JSON string) into
55
+ * an outer JSON envelope that tracks the wall-clock write time for optional
56
+ * max-age expiry. Reading a legacy entry without `_cachedAt` treats it as
57
+ * fresh (backward-compatible with plain-string caches).
58
+ *
59
+ * All `get`/`set` errors are swallowed (the {@link PullCache} contract
60
+ * requires implementations not to throw) — a failing KV store simply
61
+ * degrades to "no cache" without crashing the app.
62
+ */
63
+ export declare function createKvPullCache(kv: KvStore, opts?: KvPullCacheOptions): PullCache;
package/dist/logger.d.ts CHANGED
@@ -5,6 +5,9 @@ export interface SyncMetrics {
5
5
  conflictCount?: number;
6
6
  retryCount?: number;
7
7
  cacheHit?: boolean;
8
+ /** Elements an append-log pull dropped under `onElementError: "skip"`
9
+ * (failed verification/decryption). Omitted when none were skipped. */
10
+ skippedCount?: number;
8
11
  }
9
12
  /** Structured logger for sync operations. */
10
13
  export interface SyncLogger {
package/dist/logger.js ADDED
@@ -0,0 +1,80 @@
1
+ /** Console-based sync logger with structured output. */
2
+ export const consoleSyncLogger = {
3
+ pullStart: (s) => console.log(`[starfish:${s}] pull started`),
4
+ pullSuccess: (s, ms, m) => {
5
+ let msg = `[starfish:${s}] pull OK (${ms}ms)`;
6
+ if (m?.bytesTransferred)
7
+ msg += ` ${m.bytesTransferred}B`;
8
+ if (m?.cacheHit)
9
+ msg += ` (cache hit)`;
10
+ console.log(msg);
11
+ },
12
+ pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
13
+ pushStart: (s) => console.log(`[starfish:${s}] push started`),
14
+ pushSuccess: (s, ms, m) => {
15
+ let msg = `[starfish:${s}] push OK (${ms}ms)`;
16
+ if (m?.bytesTransferred)
17
+ msg += ` ${m.bytesTransferred}B`;
18
+ console.log(msg);
19
+ },
20
+ pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
21
+ conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
22
+ };
23
+ /** Silent sync logger (no output). */
24
+ export const noopSyncLogger = {
25
+ pullStart: () => { },
26
+ pullSuccess: () => { },
27
+ pullError: () => { },
28
+ pushStart: () => { },
29
+ pushSuccess: () => { },
30
+ pushError: () => { },
31
+ conflict: () => { },
32
+ };
33
+ /** Create a metrics collector that accumulates sync statistics. */
34
+ export function createMetricsCollector() {
35
+ const stores = new Map();
36
+ function ensureStore(name) {
37
+ let s = stores.get(name);
38
+ if (!s) {
39
+ s = { totalPulls: 0, totalPushes: 0, totalDurationMs: 0, totalBytes: 0, totalConflicts: 0 };
40
+ stores.set(name, s);
41
+ }
42
+ return s;
43
+ }
44
+ return {
45
+ recordPull(name, durationMs, metrics) {
46
+ const s = ensureStore(name);
47
+ s.totalPulls++;
48
+ s.totalDurationMs += durationMs;
49
+ if (metrics?.bytesTransferred)
50
+ s.totalBytes += metrics.bytesTransferred;
51
+ },
52
+ recordPush(name, durationMs, metrics) {
53
+ const s = ensureStore(name);
54
+ s.totalPushes++;
55
+ s.totalDurationMs += durationMs;
56
+ if (metrics?.bytesTransferred)
57
+ s.totalBytes += metrics.bytesTransferred;
58
+ },
59
+ recordConflict(name) {
60
+ ensureStore(name).totalConflicts++;
61
+ },
62
+ getSummary() {
63
+ const result = {};
64
+ for (const [name, s] of stores) {
65
+ const totalOps = s.totalPulls + s.totalPushes;
66
+ result[name] = {
67
+ totalPulls: s.totalPulls,
68
+ totalPushes: s.totalPushes,
69
+ avgDurationMs: totalOps > 0 ? Math.round(s.totalDurationMs / totalOps) : 0,
70
+ totalBytes: s.totalBytes,
71
+ totalConflicts: s.totalConflicts,
72
+ };
73
+ }
74
+ return result;
75
+ },
76
+ reset() {
77
+ stores.clear();
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Creates a migration runner that upgrades documents to the current schema version.
3
+ *
4
+ * Given a document with `_schemaVersion`, applies each migration in sequence
5
+ * until the document reaches `currentVersion`. Throws if the document version
6
+ * is ahead of the app (forward compatibility guard).
7
+ */
8
+ export function createMigrator(config) {
9
+ // Eagerly validate the migration chain
10
+ for (let v = 1; v < config.currentVersion; v++) {
11
+ if (!config.migrations[v]) {
12
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
13
+ }
14
+ }
15
+ return (data) => {
16
+ const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
17
+ if (version > config.currentVersion) {
18
+ throw new Error(`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`);
19
+ }
20
+ if (version === config.currentVersion)
21
+ return data;
22
+ let result = { ...data };
23
+ for (let v = version; v < config.currentVersion; v++) {
24
+ const fn = config.migrations[v];
25
+ if (!fn) {
26
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
27
+ }
28
+ try {
29
+ result = fn(result);
30
+ }
31
+ catch (err) {
32
+ throw new Error(`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
33
+ }
34
+ }
35
+ result._schemaVersion = config.currentVersion;
36
+ return result;
37
+ };
38
+ }
@@ -1,5 +1,5 @@
1
1
  import type { StoreApi } from "zustand/vanilla";
2
- import type { StarfishStore } from "./bindings/zustand.js";
2
+ import type { StarfishStore, StarfishLogStore } from "./bindings/zustand.js";
3
3
  /**
4
4
  * Minimal interface matching React Native's `AppState` module.
5
5
  * Pass `AppState` from `react-native` directly.
@@ -69,3 +69,30 @@ export interface MobileLifecycleOptions {
69
69
  * @returns A cleanup function that removes all event listeners.
70
70
  */
71
71
  export declare function createMobileLifecycle(store: StoreApi<StarfishStore>, deps: MobileLifecycleDeps, options?: MobileLifecycleOptions): () => void;
72
+ export interface AppendLogLifecycleOptions {
73
+ /**
74
+ * Pull new elements when the app returns to the foreground.
75
+ * Only pulls if the store is online and not already loading.
76
+ * Default: `true`.
77
+ */
78
+ pullOnForeground?: boolean;
79
+ }
80
+ /**
81
+ * Wires React Native app lifecycle events to an append-log store
82
+ * (`createStarfishLog`). A log is read-only, so this only pulls on foreground
83
+ * (there is nothing to flush on background). NetInfo connectivity changes are
84
+ * forwarded to `store.getState().setOnline()`.
85
+ *
86
+ * ```ts
87
+ * import { AppState } from "react-native"
88
+ * import NetInfo from "@react-native-community/netinfo"
89
+ * import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
90
+ *
91
+ * const store = createStarfishLog({ cursor })
92
+ * const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
93
+ * useEffect(() => cleanup, [])
94
+ * ```
95
+ *
96
+ * @returns A cleanup function that removes all event listeners.
97
+ */
98
+ export declare function createAppendLogMobileLifecycle(store: StoreApi<StarfishLogStore>, deps: MobileLifecycleDeps, options?: AppendLogLifecycleOptions): () => void;
@@ -0,0 +1,94 @@
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((err) => { console.error("[Starfish] background flush failed:", err); });
35
+ }
36
+ }
37
+ else if (appState === "active" && pullOnForeground) {
38
+ const { online, syncing } = store.getState();
39
+ if (online && !syncing) {
40
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground pull failed:", err); });
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
+ }
56
+ /**
57
+ * Wires React Native app lifecycle events to an append-log store
58
+ * (`createStarfishLog`). A log is read-only, so this only pulls on foreground
59
+ * (there is nothing to flush on background). NetInfo connectivity changes are
60
+ * forwarded to `store.getState().setOnline()`.
61
+ *
62
+ * ```ts
63
+ * import { AppState } from "react-native"
64
+ * import NetInfo from "@react-native-community/netinfo"
65
+ * import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
66
+ *
67
+ * const store = createStarfishLog({ cursor })
68
+ * const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
69
+ * useEffect(() => cleanup, [])
70
+ * ```
71
+ *
72
+ * @returns A cleanup function that removes all event listeners.
73
+ */
74
+ export function createAppendLogMobileLifecycle(store, deps, options = {}) {
75
+ const { pullOnForeground = true } = options;
76
+ const appSub = deps.appState.addEventListener("change", (appState) => {
77
+ if (appState === "active" && pullOnForeground) {
78
+ const { online, loading } = store.getState();
79
+ if (online && !loading) {
80
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground log pull failed:", err); });
81
+ }
82
+ }
83
+ });
84
+ let netUnsub = null;
85
+ if (deps.netInfo) {
86
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
87
+ store.getState().setOnline(!!isConnected);
88
+ });
89
+ }
90
+ return () => {
91
+ appSub.remove();
92
+ netUnsub?.();
93
+ };
94
+ }
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Read-modify-write a document with hash-CAS conflict retry.
3
+ *
4
+ * The everyday way to atomically edit a synced document: pull the current
5
+ * version, apply a pure `mutator` to its data, push the result with the read
6
+ * hash, and retry on a {@link ConflictError} (a concurrent writer moved the hash)
7
+ * by re-reading FRESH server state and re-applying the mutator. A missing
8
+ * document (404) is surfaced to the mutator as `{ data: null, hash: null }` so it
9
+ * can create the doc on first write.
10
+ *
11
+ * This replaces the ad-hoc `for (attempt…) { pull; mutate; try push catch
12
+ * ConflictError }` loop that applications otherwise hand-roll around every
13
+ * editable doc. The `mutator` MUST be idempotent — it re-runs on each retry — and
14
+ * returns `null` to signal a no-op (nothing changed; skip the write).
15
+ */
16
+ import { StarfishClient } from "./client.js";
17
+ /** The current state handed to a {@link DocMutator}: the document data (or `null`
18
+ * when the doc does not exist yet) and the hash to base the next push on. */
19
+ export interface DocState<T> {
20
+ data: T | null;
21
+ hash: string | null;
22
+ }
23
+ /**
24
+ * Pure transform from the current document to the next. Return the full next
25
+ * document body to write, or `null` for a no-op (the write is skipped). Runs once
26
+ * per attempt on freshly-pulled state, so it must be idempotent.
27
+ */
28
+ export type DocMutator<T> = (cur: DocState<T>) => T | null;
29
+ export interface MutateDocOptions {
30
+ /** Max push attempts before a persistent conflict propagates. Default 3. */
31
+ maxAttempts?: number;
32
+ }
33
+ /**
34
+ * Atomically read-modify-write the document at `path`. Returns the document that
35
+ * was written, or `null` if the mutator signalled a no-op. Throws the underlying
36
+ * error on a non-conflict failure, or a {@link ConflictError} if every attempt
37
+ * raced and lost.
38
+ */
39
+ export declare function mutateDoc<T extends Record<string, unknown> = Record<string, unknown>>(client: StarfishClient, path: string, mutator: DocMutator<T>, options?: MutateDocOptions): Promise<T | null>;
@@ -0,0 +1,52 @@
1
+ const DEFAULT_INTERVALS = {
2
+ "slow-2g": 120_000,
3
+ "2g": 60_000,
4
+ "3g": 30_000,
5
+ "4g": 10_000,
6
+ };
7
+ const DEFAULT_FALLBACK_MS = 15_000;
8
+ /**
9
+ * Start periodic pulling at a fixed interval.
10
+ * Skips pulls when offline or already syncing.
11
+ * Returns a cleanup function that stops polling.
12
+ */
13
+ export function startPolling(pullFn, getState, intervalMs = 30_000) {
14
+ const timer = setInterval(() => {
15
+ const { online, syncing } = getState();
16
+ if (online && !syncing)
17
+ pullFn().catch((err) => { console.error("[Starfish] poll failed:", err); });
18
+ }, intervalMs);
19
+ return () => clearInterval(timer);
20
+ }
21
+ /**
22
+ * Start polling with adaptive intervals based on network quality.
23
+ * Uses the Network Information API (`navigator.connection.effectiveType`) when available.
24
+ * Returns controls to pause, resume, or stop polling.
25
+ */
26
+ export function startAdaptivePolling(pullFn, getState, options) {
27
+ let intervalMs;
28
+ if (options?.intervalMs != null) {
29
+ intervalMs = options.intervalMs;
30
+ }
31
+ else {
32
+ const intervals = options?.intervals ?? DEFAULT_INTERVALS;
33
+ let effectiveType;
34
+ if (typeof navigator !== "undefined" && "connection" in navigator) {
35
+ effectiveType = navigator.connection.effectiveType;
36
+ }
37
+ intervalMs = (effectiveType != null ? intervals[effectiveType] : undefined) ?? DEFAULT_FALLBACK_MS;
38
+ }
39
+ let paused = false;
40
+ const timer = setInterval(() => {
41
+ if (paused)
42
+ return;
43
+ const { online, syncing } = getState();
44
+ if (online && !syncing)
45
+ pullFn().catch((err) => { console.error("[Starfish] adaptive poll failed:", err); });
46
+ }, intervalMs);
47
+ return {
48
+ pause: () => { paused = true; },
49
+ resume: () => { paused = false; },
50
+ stop: () => clearInterval(timer),
51
+ };
52
+ }