@daveyplate/supabase-swr-entities
Version:
An entity management library for Supabase and SWR
399 lines (398 loc) • 22.9 kB
JavaScript
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";
/**
* Hook for fetching an entity by `id` or params
*/
export function useEntity(table, id, params, swrConfig) {
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(() => { var _a; return id ? data : (_a = data === null || data === void 0 ? void 0 : data.data) === null || _a === void 0 ? void 0 : _a[0]; }, [data]);
const update = useCallback(async (fields) => 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 === null || entity === void 0 ? void 0 : entity.id))
return;
mutateEntity(table, entity.id, entity, params);
}, [entity]);
return Object.assign({ entity: entity, updateEntity: update, deleteEntity: doDelete }, swr);
}
/**
* 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(table, params, swrConfig, realtimeOptions) {
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(() => 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 ? Object.assign({}, params) : null;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.lang;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.offset;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.limit;
const room = useMemo(() => {
return (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.room) || (Object.keys(roomNameParams || {}).length ? `${table}:${JSON.stringify(roomNameParams)}` : table) || undefined;
}, [realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.room, JSON.stringify(params)]);
const peersResult = (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" ? usePeers({
enabled: realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
onData,
room
}) : { sendData: () => { } };
const { sendData } = peersResult;
// Mutate & precache all children entities on change
useEffect(() => {
entities === null || entities === void 0 ? void 0 : entities.forEach((entity) => {
mutateChild(table, entity.id, entity, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.user) && mutateChild("profiles", entity.user.id, entity.user, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.sender) && mutateChild("profiles", entity.sender.id, entity.sender, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.recipient) && mutateChild("profiles", entity.recipient.id, entity.recipient, (params === null || params === void 0 ? void 0 : 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, newEntity) => {
const filteredData = removeEntity(data, newEntity.id);
filteredData.data.push(newEntity);
return Object.assign(Object.assign({}, filteredData), { count: filteredData.data.count });
}, []);
const removeEntity = useCallback((data, id) => {
const filteredEntities = data.data.filter((entity) => entity.id != id);
return Object.assign(Object.assign({}, data), { data: filteredEntities, count: filteredEntities.count });
}, []);
const mutateEntity = useCallback((entity) => {
if (!entity)
return;
mutate((prev) => appendEntity(prev, entity), false);
}, [mutate]);
// Supabase Realtime
useEffect(() => {
if (!(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) || !room)
return;
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : 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 === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled, realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider, mutate, room]);
const create = useCallback(async (entity, optimisticFields) => {
const newEntity = Object.assign(Object.assign({ id: v4() }, entity), { locale: params === null || params === void 0 ? void 0 : 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) => appendEntity(currentData, Object.assign(Object.assign({ created_at: new Date() }, newEntity), optimisticFields)),
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "create_entity" });
}
return { entity };
}
catch (error) {
return { error: error };
}
}, [
session,
mutate,
createEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
const update = useCallback(async (id, fields) => {
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) => {
const entity = currentData.data.find((e) => e.id == id);
if (!entity)
return data;
return appendEntity(currentData, Object.assign(Object.assign({ updated_at: new Date() }, entity), fields));
},
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "update_entity" });
}
return { entity };
}
catch (error) {
return { error: error };
}
}, [
mutate,
updateEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
const doDelete = useCallback(async (id) => {
try {
await mutate(async () => {
const { error } = await deleteEntity(table, id, params);
if (error)
throw error;
}, {
populateCache: (_, currentData) => removeEntity(currentData, id),
optimisticData: (currentData) => removeEntity(currentData, id),
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "delete_entity" });
}
}
catch (error) {
return { error: error };
}
return { success: true };
}, [
mutate,
deleteEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
return Object.assign(Object.assign(Object.assign({}, swr), peersResult), { entities: entities, 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(table, params, swrConfig, realtimeOptions) {
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(() => (data === null || data === void 0 ? void 0 : 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(() => (data === null || data === void 0 ? void 0 : 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 ? Object.assign({}, params) : null;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.lang;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.offset;
roomNameParams === null || roomNameParams === void 0 ? true : delete roomNameParams.limit;
const room = useMemo(() => {
return (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.room) || (Object.keys(roomNameParams || {}).length ? `${table}:${JSON.stringify(roomNameParams)}` : table);
}, [realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.room, JSON.stringify(params)]);
const peersResult = (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" ? usePeers({
enabled: realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
onData,
room
}) : { sendData: () => { } };
const { sendData } = peersResult;
// Mutate all children entities after each validation
useEffect(() => {
entities === null || entities === void 0 ? void 0 : entities.forEach((entity) => {
mutateChild(table, entity.id, entity, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.user) && mutateChild("profiles", entity.user.id, entity.user, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.sender) && mutateChild("profiles", entity.sender.id, entity.sender, (params === null || params === void 0 ? void 0 : params.lang) ? { lang: params.lang } : null);
(entity === null || entity === void 0 ? void 0 : entity.recipient) && mutateChild("profiles", entity.recipient.id, entity.recipient, (params === null || params === void 0 ? void 0 : 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, newEntity) => {
// 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, newEntity) => {
// Find this entity in a page and replace it with newEntity
const amendedPages = data.map((page) => {
const amendedData = page.data.map((entity) => entity.id == newEntity.id ? newEntity : entity);
return Object.assign(Object.assign({}, page), { data: amendedData });
});
return amendedPages;
}, []);
const removeEntity = useCallback((data, id) => {
// Filter this entity from all pages
return data.map((page) => {
const filteredData = page.data.filter((entity) => entity.id != id);
return Object.assign(Object.assign({}, page), { data: filteredData });
});
}, []);
const mutateEntity = useCallback((entity) => {
entity && mutate((prev) => amendEntity(prev, entity), false);
}, [mutate]);
// Supabase Realtime
useEffect(() => {
if (!(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) || !room)
return;
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : 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 === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled, realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider, room, mutate]);
const create = useCallback(async (entity, optimisticFields) => {
// Mutate the new entity directly to the parent cache
const newEntity = Object.assign(Object.assign({ id: v4() }, entity), { locale: params === null || params === void 0 ? void 0 : 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, entities[0]),
optimisticData: (currentData) => {
return appendEntity(currentData, Object.assign(Object.assign({ created_at: new Date() }, newEntity), optimisticFields));
},
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "create_entity" });
}
return { entity };
}
catch (error) {
return { error: error };
}
}, [
session,
mutate,
createEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
const update = useCallback(async (id, fields) => {
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, entities[0]),
optimisticData: (currentData) => {
const entity = currentData === null || currentData === void 0 ? void 0 : currentData.map(page => page.data).flat().find((e) => e.id == id);
if (!entity)
return currentData;
return amendEntity(currentData, Object.assign(Object.assign({ updated_at: new Date() }, entity), fields));
},
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "update_entity" });
}
return { entity };
}
catch (error) {
return { error: error };
}
}, [
session,
mutate,
updateEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
const doDelete = useCallback(async (id) => {
try {
await mutate(async () => {
const { error } = await deleteEntity(table, id, params);
if (error)
throw error;
return [];
}, {
populateCache: (_, currentData) => removeEntity(currentData, id),
optimisticData: (currentData) => removeEntity(currentData, id),
revalidate: false
});
if ((realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled) && (realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider) == "peerjs" && !(realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly)) {
sendData({ event: "delete_entity" });
}
}
catch (error) {
return { error: error };
}
return { success: true };
}, [
mutate,
deleteEntity,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.enabled,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.provider,
realtimeOptions === null || realtimeOptions === void 0 ? void 0 : realtimeOptions.listenOnly,
sendData,
JSON.stringify(params)
]);
return Object.assign(Object.assign(Object.assign({}, swr), peersResult), { entities: entities, count,
limit,
offset,
hasMore, createEntity: create, updateEntity: update, deleteEntity: doDelete, mutateEntity });
}