UNPKG

@daveyplate/supabase-swr-entities

Version:

An entity management library for Supabase and SWR

574 lines (494 loc) 20.9 kB
import { useEffect, useMemo, useCallback } from "react" import { useSession, useSupabaseClient } from "@supabase/auth-helpers-react" import { v4 } from "uuid" import { usePeers } from "./use-peers" import { apiPath } from "./client-utils" import { useCreateEntity, useDeleteEntity, useMutateEntity, useUpdateEntity } from "./use-entity-helpers" import { useCache, useInfiniteCache } from "./use-cache-hooks" import { SWRConfiguration, SWRResponse } from "swr" import { SWRInfiniteResponse } from "swr/infinite" interface EntityResponse<T> extends SWRResponse { entity: T updateEntity: (fields: Record<string, any>) => Promise<{ entity?: Record<string, any>, error?: Error }> deleteEntity: () => Promise<{ success?: boolean, error?: Error }> } /** * Hook for fetching an entity by `id` or params */ export function useEntity<T = Record<string, any>>( table?: string | null, id?: string | null, params?: Record<string, any> | null, swrConfig?: SWRConfiguration | null ): EntityResponse<T> { const updateEntity = useUpdateEntity() const deleteEntity = useDeleteEntity() const mutateEntity = useMutateEntity() const path = apiPath(table, id, params) const swr = useCache(path, swrConfig) const { data } = swr const entity = useMemo<Record<string, any>>(() => id ? data : data?.data?.[0], [data]) const update = useCallback(async (fields: Record<string, any>) => updateEntity(table, id, fields, params), [table, id, entity, JSON.stringify(params)]) const doDelete = useCallback(async () => deleteEntity(table, id, params), [table, id, entity, JSON.stringify(params)]) // Pre-mutate the entity for ID for "me" or in case that ID isn't set useEffect(() => { if (!entity) return if (id == entity?.id) return mutateEntity(table, entity.id, entity, params) }, [entity]) return { entity: entity as T, updateEntity: update, deleteEntity: doDelete, ...swr, } } interface EntitiesData { data: Record<string, any>[] count: number limit: number offset: number has_more: boolean } interface SharedEntitiesResponse<T> { entities: T[] count: number limit: number offset: number hasMore: boolean createEntity: (entity: object, optimisticFields?: object) => Promise<{ entity?: object; error?: Error }> updateEntity: (id: string, fields: object) => Promise<{ entity?: object; error?: Error }> deleteEntity: (id: string) => Promise<{ error?: Error }> mutateEntity: (entity: object) => void } interface EntitiesResponse<T> extends SharedEntitiesResponse<T>, SWRResponse { } interface InfiniteEntitiesResponse<T> extends SharedEntitiesResponse<T>, SWRInfiniteResponse { } interface RealtimeOptions { enabled?: boolean provider?: "peerjs" | "supabase" room?: string listenOnly?: boolean } /** * Hook for fetching entities * @param {string} table - The table name * @param {Record<string, any>} [params] - The query parameters * @param {SWRConfiguration} [swrConfig] - The SWR config * @param {RealtimeOptions} [realtimeOptions] - The Realtime options * @param {boolean} [realtimeOptions.enabled] - Whether Realtime is enabled * @param {string} [realtimeOptions.provider] - The Realtime provider * @param {string} [realtimeOptions.room] - The Realtime room * @param {boolean} [realtimeOptions.listenOnly=false] - Whether to only listen for Realtime data */ export function useEntities<T = Record<string, any>>( table?: string | null, params?: Record<string, any> | null, swrConfig?: SWRConfiguration | null, realtimeOptions?: RealtimeOptions | null ): EntitiesResponse<T> { const session = useSession() const supabase = useSupabaseClient() const createEntity = useCreateEntity() const updateEntity = useUpdateEntity() const deleteEntity = useDeleteEntity() const mutateChild = useMutateEntity() const path = apiPath(table, null, params) const swr = useCache(path, swrConfig) const { data, mutate } = swr const { data: entities, count, limit, offset, has_more: hasMore } = useMemo<EntitiesData>(() => data || {}, [data]) // Reload the entities whenever realtime data is received const onData = useCallback(() => { mutate() }, [mutate]) // Clean out the params for the room name const roomNameParams = params ? { ...params } : null delete roomNameParams?.lang delete roomNameParams?.offset delete roomNameParams?.limit const room = useMemo<string | undefined>(() => { return realtimeOptions?.room || (Object.keys(roomNameParams || {}).length ? `${table}:${JSON.stringify(roomNameParams)}` : table) || undefined }, [realtimeOptions?.room, JSON.stringify(params)]) const peersResult = realtimeOptions?.provider == "peerjs" ? usePeers({ enabled: realtimeOptions?.enabled, onData, room }) : { sendData: () => { } } const { sendData } = peersResult // Mutate & precache all children entities on change useEffect(() => { entities?.forEach((entity) => { mutateChild(table, entity.id, entity, params?.lang ? { lang: params.lang } : null) entity?.user && mutateChild("profiles", entity.user.id, entity.user, params?.lang ? { lang: params.lang } : null) entity?.sender && mutateChild("profiles", entity.sender.id, entity.sender, params?.lang ? { lang: params.lang } : null) entity?.recipient && mutateChild("profiles", entity.recipient.id, entity.recipient, params?.lang ? { lang: params.lang } : null) }) }, [entities, mutateChild, table, JSON.stringify(params)]) // Append an entity to the data & filter out duplicates const appendEntity = useCallback((data: Record<string, any>, newEntity: Record<string, any>) => { const filteredData = removeEntity(data, newEntity.id) filteredData.data.push(newEntity) return { ...filteredData, count: filteredData.data.count } }, []) const removeEntity = useCallback((data: Record<string, any>, id: string) => { const filteredEntities = data.data.filter((entity: Record<string, any>) => entity.id != id) return { ...data, data: filteredEntities, count: filteredEntities.count } }, []) const mutateEntity = useCallback((entity: Record<string, any>) => { if (!entity) return mutate((prev: Record<string, any>) => appendEntity(prev, entity), false) }, [mutate]) // Supabase Realtime useEffect(() => { if (!realtimeOptions?.enabled || !room) return if (realtimeOptions?.provider != "supabase") return const channelA = supabase.channel(room, { config: { private: true } }) // Subscribe to the Channel channelA.on('broadcast', { event: '*' }, () => mutate() ).subscribe() return () => { channelA.unsubscribe() } }, [realtimeOptions?.enabled, realtimeOptions?.provider, mutate, room]) const create = useCallback(async ( entity: Record<string, any>, optimisticFields?: Record<string, any> ): Promise<{ entity?: Record<string, any>, error?: Error }> => { const newEntity = { id: v4(), ...entity, locale: params?.lang } try { const entity = await mutate(async () => { const { entity, error } = await createEntity(table, newEntity, params, optimisticFields) if (error) throw error return entity }, { populateCache: (entity, currentData) => appendEntity(currentData, entity), optimisticData: (currentData: Record<string, any>) => appendEntity(currentData, { created_at: new Date(), ...newEntity, ...optimisticFields }), revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "create_entity" }) } return { entity } } catch (error) { return { error: error as Error } } }, [ session, mutate, createEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) const update = useCallback(async ( id: string, fields: Record<string, any> ): Promise<{ entity?: Record<string, any>, error?: Error }> => { try { const entity = await mutate(async () => { const { entity, error } = await updateEntity(table, id, fields, params) if (error) throw error return entity }, { populateCache: (entity, currentData) => appendEntity(currentData, entity), optimisticData: (currentData: Record<string, any>) => { const entity = currentData.data.find((e: Record<string, any>) => e.id == id) if (!entity) return data return appendEntity(currentData, { updated_at: new Date(), ...entity, ...fields }) }, revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "update_entity" }) } return { entity } } catch (error) { return { error: error as Error } } }, [ mutate, updateEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) const doDelete = useCallback(async (id: string): Promise<{ success?: boolean, error?: Error }> => { try { await mutate(async () => { const { error } = await deleteEntity(table, id, params) if (error) throw error }, { populateCache: (_, currentData) => removeEntity(currentData, id), optimisticData: (currentData: Record<string, any>) => removeEntity(currentData, id), revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "delete_entity" }) } } catch (error) { return { error: error as Error } } return { success: true } }, [ mutate, deleteEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) return { ...swr, ...peersResult, entities: entities as T[], count, limit, offset, hasMore, createEntity: create, updateEntity: update, deleteEntity: doDelete, mutateEntity } } /** * Hook for fetching entities with infinite scrolling support * @param {string} table - The table name * @param {SWRConfiguration} [swrConfig] - The SWR config * @param {RealtimeOptions} [realtimeOptions] - The Realtime options * @param {boolean} [realtimeOptions.enabled] - Whether Realtime is enabled * @param {string} [realtimeOptions.provider] - The Realtime provider * @param {string} [realtimeOptions.room] - The Realtime room * @param {boolean} [realtimeOptions.listenOnly=false] - Whether to only listen for Realtime data */ export function useInfiniteEntities<T = Record<string, any>>( table?: string | null, params?: Record<string, any> | null, swrConfig?: SWRConfiguration | null, realtimeOptions?: RealtimeOptions | null ): InfiniteEntitiesResponse<T> { const session = useSession() const supabase = useSupabaseClient() const createEntity = useCreateEntity() const updateEntity = useUpdateEntity() const deleteEntity = useDeleteEntity() const mutateChild = useMutateEntity() // Load the entity pages using SWR const path = apiPath(table, null, params) const swr = useInfiniteCache(path, swrConfig) const { data, mutate } = swr // Memoize the merged pages into entities and filter out duplicates const entities = useMemo<Record<string, any>[]>(() => data?.map(page => page.data).flat() .filter((entity, index, self) => index === self.findIndex((t) => ( t.id === entity.id )) ) || [], [data]) // Set the other vars from the final page const { offset, limit, has_more: hasMore, count } = useMemo<EntitiesData>(() => data?.[data.length - 1] || {}, [data]) // Reload the entities whenever realtime data is received const onData = useCallback(() => { mutate() }, [mutate]) // Clean out the params for the room name const roomNameParams = params ? { ...params } : null delete roomNameParams?.lang delete roomNameParams?.offset delete roomNameParams?.limit const room = useMemo(() => { return realtimeOptions?.room || (Object.keys(roomNameParams || {}).length ? `${table}:${JSON.stringify(roomNameParams)}` : table) }, [realtimeOptions?.room, JSON.stringify(params)]) const peersResult = realtimeOptions?.provider == "peerjs" ? usePeers({ enabled: realtimeOptions?.enabled, onData, room }) : { sendData: () => { } } const { sendData } = peersResult // Mutate all children entities after each validation useEffect(() => { entities?.forEach((entity) => { mutateChild(table, entity.id, entity, params?.lang ? { lang: params.lang } : null) entity?.user && mutateChild("profiles", entity.user.id, entity.user, params?.lang ? { lang: params.lang } : null) entity?.sender && mutateChild("profiles", entity.sender.id, entity.sender, params?.lang ? { lang: params.lang } : null) entity?.recipient && mutateChild("profiles", entity.recipient.id, entity.recipient, params?.lang ? { lang: params.lang } : null) }) }, [entities, mutateChild, table, JSON.stringify(params)]) // Append an entity to the data & filter out duplicates const appendEntity = useCallback((data: Record<string, any>, newEntity: Record<string, any>) => { // Filter this entity from all pages then push it to the first page const filteredPages = removeEntity(data, newEntity.id) filteredPages[0].data.push(newEntity) return filteredPages }, []) const amendEntity = useCallback((data: Record<string, any>, newEntity: Record<string, any>) => { // Find this entity in a page and replace it with newEntity const amendedPages = data.map((page: Record<string, any>) => { const amendedData = page.data.map((entity: Record<string, any>) => entity.id == newEntity.id ? newEntity : entity) return { ...page, data: amendedData } }) return amendedPages }, []) const removeEntity = useCallback((data: Record<string, any>, id: string) => { // Filter this entity from all pages return data.map((page: Record<string, any>) => { const filteredData = page.data.filter((entity: Record<string, any>) => entity.id != id) return { ...page, data: filteredData } }) }, []) const mutateEntity = useCallback((entity: Record<string, any>) => { entity && mutate((prev) => amendEntity(prev as Record<string, any>, entity), false) }, [mutate]) // Supabase Realtime useEffect(() => { if (!realtimeOptions?.enabled || !room) return if (realtimeOptions?.provider != "supabase") return const channelA = supabase.channel(room, { config: { private: true } }) // Subscribe to the Channel channelA.on('broadcast', { event: '*' }, () => mutate() ).subscribe() return () => { channelA.unsubscribe() } }, [realtimeOptions?.enabled, realtimeOptions?.provider, room, mutate]) const create = useCallback(async ( entity: Record<string, any>, optimisticFields?: Record<string, any> ): Promise<{ entity?: Record<string, any>, error?: Error }> => { // Mutate the new entity directly to the parent cache const newEntity = { id: v4(), ...entity, locale: params?.lang } try { const entity = await mutate(async () => { const { entity, error } = await createEntity(table, newEntity, params, optimisticFields) if (error || !entity) throw error return [entity] }, { populateCache: (entities, currentData) => appendEntity(currentData as Record<string, any>, entities[0]), optimisticData: (currentData) => { return appendEntity(currentData as Record<string, any>, { created_at: new Date(), ...newEntity, ...optimisticFields }) }, revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "create_entity" }) } return { entity } } catch (error) { return { error: error as Error } } }, [ session, mutate, createEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) const update = useCallback(async ( id: string, fields: Record<string, any> ): Promise<{ entity?: Record<string, any>, error?: Error }> => { try { const entity = await mutate(async () => { const { entity, error } = await updateEntity(table, id, fields, params) if (error) throw error return [entity] }, { populateCache: (entities, currentData) => amendEntity(currentData as Record<string, any>, entities[0]), optimisticData: (currentData) => { const entity = currentData?.map(page => page.data).flat().find((e) => e.id == id) if (!entity) return currentData return amendEntity(currentData as Record<string, any>, { updated_at: new Date(), ...entity, ...fields }) }, revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "update_entity" }) } return { entity } } catch (error) { return { error: error as Error } } }, [ session, mutate, updateEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) const doDelete = useCallback(async (id: string): Promise<{ success?: boolean, error?: Error }> => { try { await mutate(async () => { const { error } = await deleteEntity(table, id, params) if (error) throw error return [] }, { populateCache: (_, currentData) => removeEntity(currentData as Record<string, any>, id), optimisticData: (currentData) => removeEntity(currentData as Record<string, any>, id), revalidate: false }) if (realtimeOptions?.enabled && realtimeOptions?.provider == "peerjs" && !realtimeOptions?.listenOnly) { sendData({ event: "delete_entity" }) } } catch (error) { return { error: error as Error } } return { success: true } }, [ mutate, deleteEntity, realtimeOptions?.enabled, realtimeOptions?.provider, realtimeOptions?.listenOnly, sendData, JSON.stringify(params) ]) return { ...swr, ...peersResult, entities: entities as T[], count, limit, offset, hasMore, createEntity: create, updateEntity: update, deleteEntity: doDelete, mutateEntity } }