@daveyplate/supabase-swr-entities
Version:
An entity management library for Supabase and SWR
424 lines (349 loc) • 13.6 kB
text/typescript
import translate from '@iamtraction/google-translate'
import { promises as fs } from 'fs'
import path from 'path'
import defaultSchema from '../schemas/default.schema.json'
import usersSchema from '../schemas/users.schema.json'
import { createAdminClient } from '../supabase/service-role'
import { createNotification } from './notifications'
import { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js'
/**
* Load an table's entity schema from entity.schemas.json
*/
export async function loadEntitySchema(table: string): Promise<Record<string, any>> {
const entitySchemas = await loadEntitySchemas()
const entitySchema = entitySchemas.find(schema => schema.table === table)
if (!entitySchema) return { error: { message: `Schema Not Found: ${table}` } }
let mergedSchema = { ...defaultSchema }
if (entitySchema.usersSchema) {
mergedSchema = { ...mergedSchema, ...usersSchema }
}
mergedSchema = { ...mergedSchema, ...entitySchema }
return { entitySchema: mergedSchema }
}
declare global {
var entitySchemas: Record<string, any>[]
}
/**
* Load all entity schemas from entity.schemas.json
*/
export async function loadEntitySchemas(): Promise<Record<string, any>[]> {
if (global.entitySchemas && process.env.NODE_ENV !== 'development') {
return (global as any).entitySchemas
}
const filePath = path.join(process.cwd(), 'entity.schemas.json')
const file = await fs.readFile(filePath, 'utf8')
const entitySchemas: Record<string, any>[] = JSON.parse(file)
global.entitySchemas = entitySchemas
return entitySchemas
}
/**
* Translate an entity's localized fields using Google Translate
* @param {string} table SQL table for schema lookup
* @param {object} entity Entity to translate
* @param {string} lang Language to translate to
*/
export async function translateEntity(table: string, entity: Record<string, any>, lang: string) {
const entitySchema = await loadEntitySchema(table)
const localizedColumns = entitySchema.localizedColumns || []
let fromLocale = entity.locale
const translatedFields: Record<string, string> = {}
// Translate fields
for (const key of localizedColumns) {
if (entity[key]?.[fromLocale]) {
if (!entity[key][lang]) {
let localeValue = entity[key][fromLocale]
if (!localeValue) {
fromLocale = Object.keys(entity[key])[0]
localeValue = entity[key][fromLocale]
}
const translatedValue = await translate(localeValue, { from: fromLocale, to: lang })
if (translatedValue.text?.length > 0) {
entity[key][lang] = translatedValue.text
translatedFields[key] = entity[key]
}
}
}
}
// Clean out all locale fields that aren't fromLocale or lang
for (const key of localizedColumns) {
for (const locale in entity[key]) {
if (![fromLocale, lang].includes(locale)) {
delete entity[key][locale]
}
}
}
if (Object.keys(translatedFields).length > 0) {
updateEntity(table, entity.id, translatedFields)
}
}
async function sendRealtime(table: string, event: string, payload: any) {
const { entitySchema } = await loadEntitySchema(table)
if (!entitySchema?.realtime && !entitySchema.realtimeParent) return
if (entitySchema.realtimeParent) {
const { entity } = await getEntity(
entitySchema.realtimeParent.table,
payload[entitySchema.realtimeParent.column]
)
if (entity) {
await sendRealtime(entitySchema.realtimeParent.table, "update_entity", entity)
}
return
}
const room = entitySchema.realtimeIdentifier ?
`${table}:${payload[entitySchema.realtimeIdentifier]}`
: table
const supabase = createAdminClient()
const channel = supabase.channel(room, { config: { private: true } })
// No need to subscribe to channel
channel.send({
type: 'broadcast',
event,
payload,
})
// Remember to clean up the channel
supabase.removeChannel(channel)
}
/**
* Get a single entity from a SQL table
* @param {string} table SQL table to get entity from
* @param {string} [id] ID of the entity to get
* @param {object} [params={}] Additional parameters to apply to the query
* @param {string[]} [select] Values to select
*/
export async function getEntity(
table: string,
id?: string,
params: Record<string, any> = {},
select?: string[]
): Promise<{ entity?: Record<string, any>; error?: PostgrestError }> {
const lang = params?.lang
const { data, error } = await entityQuery(table, "select", {}, { id, ...params }, select)
if (error) {
console.error(error)
return { error }
}
const entity = data[0]
// Dynamic realtime translation
if (lang) {
await translateEntity(table, entity, lang)
}
return { entity }
}
/**
* Get entities from a SQL table
* @param {string} table SQL table to get entities from
* @param {{limit: number, offset: number, order: string, [key: string]: any}} [params={}] Parameters to apply to the query
* @param {string[]} [select] Values to select
*/
export async function getEntities(
table: string,
params: Record<string, any> = {},
select?: string[]
): Promise<{ entities?: Record<string, any>[]; count?: number; limit?: number; offset?: number; error?: PostgrestError }> {
const lang = params?.lang
const { data: entities, error, count } = await entityQuery(table, "select", {}, params, select)
if (error) {
console.error(error)
return { error }
}
// Dynamic realtime translation
if (lang) {
await Promise.all(entities.map(async entity => {
await translateEntity(table, entity, lang)
}))
}
return { entities, count: count!, limit: parseInt(params.limit || 100), offset: parseInt(params.offset || 0) }
}
/**
* Create an entity in a SQL table
* @param {string} table SQL table to create entity in
* @param {object} [values={}] Values to create the entity with
* @param {string[]} [select] Fields to select
*/
export async function createEntity(
table: string,
values: Record<string, any> = {},
select?: string[]
): Promise<{ entity?: Record<string, any>; error?: PostgrestError }> {
const { data, error } = await entityQuery(table, "upsert", values, {}, select)
if (error) {
console.error(error)
return { error }
}
const entity = data[0]
await sendRealtime(table, "create_entity", entity)
await createNotification(table, "upsert", entity)
return { entity }
}
/**
* Update an entity in a SQL table
* @param {string} table SQL table to update entity in
* @param {string} id ID of the entity to update
* @param {object} [values={}] Values to update the entity with
* @param {object} [params={}] Parameters to apply to the update query
* @param {string[]} [select] Fields to select
*/
export const updateEntity = async (
table: string,
id: string,
values: Record<string, any> = {},
params: Record<string, any> = {},
select?: string[]
): Promise<{ entity?: Record<string, any>; error?: PostgrestError }> => {
const { data, error } = await entityQuery(table, "update", values, { id, ...params }, select)
if (error) {
console.error(error)
return { error }
}
const entity = data[0]
await sendRealtime(table, 'update_entity', entity)
return { entity }
}
/**
* Update entities in a SQL table
* @param {string} table SQL table to update entities in
* @param {object} [values={}] Values to update the entities with
* @param {object} [params={}] Parameters to apply to the update query
*/
export async function updateEntities(
table: string,
values: Record<string, any> = {},
params: Record<string, any> = {}
): Promise<{ entities?: Record<string, any>[]; error?: PostgrestError }> {
const { data: entities, error } = await entityQuery(table, "update", values, params)
if (error) {
console.error(error)
return { error }
}
await Promise.all(entities.map(async entity => {
await sendRealtime(table, 'update_entity', entity)
}))
return { entities }
}
/**
* Delete an entity from a SQL table
* @param {string} table SQL table to delete entity from
* @param {string} id ID of the entity to delete
* @param {object} [params={}] Parameters to apply to the delete query
*/
export async function deleteEntity(
table: string,
id: string,
params: Record<string, any> = {}
): Promise<{ entity?: Record<string, any>; error?: PostgrestError }> {
const { data, error } = await entityQuery(table, "delete", null, { id, ...params })
if (error) {
console.error(error)
return { error }
}
const entity = data[0]
await sendRealtime(table, 'delete_entity', entity)
return { entity }
}
/**
* Delete entities from a SQL table
* @param {string} table SQL table to delete entity from
* @param {object} [params={}] Parameters to apply to the delete query
*/
export async function deleteEntities(
table: string,
params: Record<string, any> = {}
): Promise<{ entities?: Record<string, any>[]; error?: PostgrestError }> {
const { data: entities, error } = await entityQuery(table, "delete", null, params)
if (error) {
console.error(error)
return { error }
}
await Promise.all(entities.map(async entity => {
await sendRealtime(table, 'update_entity', entity)
}))
return { entities }
}
/**
* Build a query for a SQL table
* @param {string} table SQL table to build query for
* @param {string} operation Operation to use for the query
* @param {object} [values] Values to use in the query
* @param {object} [params] Parameters to apply to the query
* @param {string[]} [select] Fields to select
*/
export async function entityQuery(
table: string,
operation: "select" | "delete" | "update" | "upsert",
values?: Record<string, any> | null,
params: Record<string, any> = {},
select?: string[]
): Promise<PostgrestSingleResponse<Error[]>> {
delete params?.lang
const entitySchemas = await loadEntitySchemas()
const supabase = createAdminClient()
const entitySchema = entitySchemas.find(schema => schema.table === table)
if (!entitySchema) throw new Error('Schema not found')
// Build query based on method
let query: any = supabase.from(table)
if (operation == "select") {
query = supabase.from(table)
} else if (operation == "delete") {
query = supabase.from(table).delete()
} else if (operation == "update") {
query = supabase.from(table).update(values)
} else if (operation == "upsert") {
query = supabase.from(table).upsert(values, { ignoreDuplicates: !!entitySchema.ignoreDuplicates, onConflict: entitySchema.onConflict })
}
if (!query) throw new Error('Query not found')
// Select values with default fallback
const selectValues = (select || entitySchema.select)?.join(', ')
query = query.select(selectValues, { count: 'exact' })
// Sort order
const order = params.order || entitySchema.defaultOrder
if (order) {
const orderParams = order.split(',')
orderParams.forEach((param: string) => {
const isDesc = param.startsWith('-')
const field = isDesc ? param.slice(1) : param
query = query.order(field, { ascending: !isDesc })
})
}
// Pagination
let { limit = operation == "select" ? 100 : 0, offset = 0 } = params
limit = Math.min(limit, 100)
if (limit) {
if (offset) {
query = query.range(offset, parseInt(offset) + parseInt(limit) - 1)
} else {
query = query.limit(parseInt(limit))
}
}
// Apply additional parameters to the query
if (operation != "upsert") {
for (let [key, value] of Object.entries(params)) {
if (['limit', 'offset', 'order'].includes(key)) continue
if (key == 'or') {
query = query.or(value)
} else if (key.endsWith('_neq')) {
query = query.neq(key.slice(0, -4), value)
} else if (key.endsWith('_in')) {
query = query.in(key.slice(0, -3), value.split(','))
} else if (key.endsWith('_like')) {
query = query.ilike(key.slice(0, -5), `%${value}%`)
} else if (key.endsWith('_ilike')) {
query = query.ilike(key.slice(0, -6), `%${value}%`)
} else if (key.endsWith('_search')) {
query = query.textSearch(key.slice(0, -7), `'${value}'`, { type: 'websearch' })
} else if (key.endsWith('_gt')) {
query = query.gt(key.slice(0, -3), value)
} else if (key.endsWith('_lt')) {
query = query.lt(key.slice(0, -3), value)
} else if (key.endsWith('_gte')) {
query = query.gte(key.slice(0, -3), value)
} else if (key.endsWith('_lte')) {
query = query.lte(key.slice(0, -3), value)
} else if (value == "null" || value == null) {
query = query.is(key, null)
} else {
query = query.eq(key, value)
}
}
}
return await query
}