UNPKG

@fine-dev/fine-js

Version:

Javascript client for Fine BaaS

349 lines (306 loc) 11.5 kB
import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react" import { Fetch } from "./types" /** Utility types for extracting stuff from the schema */ type Trim<S extends string> = S extends ` ${infer R}` | `${infer R} ` ? Trim<R> : S type Split<S extends string, D extends string = ","> = S extends `${infer T}${D}${infer U}` ? [Trim<T>, ...Split<U, D>] : [Trim<S>] type FilterKeys<T, L extends string[]> = L[number] extends keyof T ? L : never type ExtractInvalidKeys<T, S extends string> = Exclude<Split<S>[number], keyof T> type ExtractKeys<T, S extends string> = FilterKeys<T, Split<S>>[number] type ExtractValues<T, S extends string> = ExtractKeys<T, S> extends never ? ParserError<`Invalid columns provided: ${ExtractInvalidKeys<T, S>}`> : { [K in Extract<ExtractKeys<T, S>, keyof T>]: T[K] } type ParserError<Message extends string> = { error: true } & Message type Table = Record<string, any> export type GenericSchema = Record<string, Table> // Types for hooks type NotProvided = "NOT_PROVIDED" type RTFromQuery<Query extends string, T extends Table> = Query extends NotProvided ? void : Query extends "*" ? T : ExtractValues<T, Query> type ListOrSingle<T> = T | T[] type QueryFilterBuilder<T extends Table, RT> = (builder: D1FilterBuilder<T, T>) => D1FilterBuilder<T, RT> type MutationBuilderFn< T extends Table, RT = ParserError<"You must chain `select` to your query to receive results"> > = (builder: D1FilterBuilder<T>) => D1FilterBuilder<T, RT> // Helper functions for hooks /** Take a value and return it unchanged */ const fallthrough = (value: any) => value /** Take an unknown error value and return it as an `Error` */ const parseError = (error: any) => { if (error instanceof Error) return error if (typeof error === "string") return new Error(error) if (typeof error === "object") return new Error(JSON.stringify(error)) return new Error("Unknown error") } function handleHookError<State extends { error: Error | null; isLoading?: boolean }>( error: any, setState: Dispatch<SetStateAction<State>> ) { const parsedError = parseError(error) setState((state) => { const newState = { ...state, error: parsedError } if ("isLoading" in state) newState.isLoading = false return newState }) return parsedError } async function handleMutation< Builder extends D1FilterBuilder<any, any>, State extends { error: Error | null; isLoading: boolean } >(builder: Builder, setState: Dispatch<SetStateAction<State>>) { try { if (!builder) return setState((state) => ({ ...state, error: null, isLoading: true })) const result = await builder setState((state) => ({ ...state, error: null, isLoading: false })) return result } catch (error) { return handleHookError(error, setState) } } export default class D1RestClient<Schema extends GenericSchema = GenericSchema> { private baseUrl: string private headers: HeadersInit public fetch: Fetch constructor({ baseUrl, headers = {}, fetch: customFetch }: { baseUrl: string headers?: HeadersInit fetch?: Fetch }) { this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl this.headers = headers this.fetch = customFetch ?? fetch.bind(window) } table<TableName extends keyof Schema>(tableName: TableName) { const url = new URL(`${this.baseUrl}/tables/${tableName as string}`) return new D1QueryBuilder<Schema[TableName]>(url, { headers: this.headers, fetch: this.fetch }) } useQuery<TableName extends keyof Schema, RT = Schema[TableName]>( tableName: TableName, builderFn: QueryFilterBuilder<Schema[TableName], RT> = fallthrough ) { const [state, setState] = useState<{ data: Required<RT>[] | null; error: Error | null; isLoading: boolean }>({ data: null, error: null, isLoading: true }) const builder = builderFn(this.table(tableName).select() as any) const builderUrl = builder?.url.toString() // Use the builder URL as a dependency for re-fetching const fetchData = useCallback(async () => { try { if (!builder) return setState({ data: null, error: null, isLoading: true }) const data = await builder setState({ data, error: null, isLoading: false }) } catch (error) { handleHookError(error, setState) } }, [tableName, builderUrl]) useEffect(() => { fetchData() }, [fetchData]) return { ...state, refetch: fetchData } } useInsert<TableName extends keyof Schema>(tableName: TableName) { const [state, setState] = this.useMutationState() const insert = useCallback( async <Query extends string = NotProvided, RT = RTFromQuery<Query, Schema[TableName]>>( values: ListOrSingle<Schema[TableName]>, returns?: Query ) => { let builder = this.table(tableName).insert(values) if (returns) builder = builder.select(returns) return (await handleMutation(builder, setState)) as | (Query extends NotProvided ? Promise<ParserError<"You must chain `select` to your query to receive results">> : RT[]) | Error }, [tableName] ) return { ...state, insert } } useUpdate<TableName extends keyof Schema>(tableName: TableName) { const [state, setState] = this.useMutationState() const update = useCallback( async <RT>( values: Partial<Schema[TableName]>, builderFn: MutationBuilderFn<Schema[TableName], RT> = fallthrough ) => { return await handleMutation(builderFn(this.table(tableName).update(values)), setState) }, [tableName] ) return { ...state, update } } useDelete<TableName extends keyof Schema>(tableName: TableName) { const [state, setState] = this.useMutationState() const deleteRecord = useCallback( async <RT>(builderFn: MutationBuilderFn<Schema[TableName], RT>) => { return await handleMutation(builderFn(this.table(tableName).delete()), setState) }, [tableName] ) return { ...state, deleteRecord } } private useMutationState() { return useState<{ error: Error | null; isLoading: boolean }>({ error: null, isLoading: false }) } } class D1QueryBuilder<T extends Table> { url: URL headers: HeadersInit fetch: Fetch constructor( url: URL, { headers = {}, fetch }: { headers?: HeadersInit fetch: Fetch } ) { this.url = url this.headers = headers this.fetch = fetch } select<Query extends string = "*", ResultType = Query extends "*" ? T : ExtractValues<T, Query>>( query: Query = "*" as Query ) { this.url.searchParams.set("select", query) return new D1FilterBuilder<T, ResultType>({ url: this.url, headers: this.headers, fetch: this.fetch, method: "GET" }) as Omit<D1FilterBuilder<T, ResultType>, "select"> } insert(values: T | T[]) { return new D1FilterBuilder<T>({ url: this.url, headers: this.headers, fetch: this.fetch, method: "POST", body: values }) } update(values: Partial<T>) { return new D1FilterBuilder<T>({ url: this.url, headers: this.headers, fetch: this.fetch, method: "PATCH", body: { data: values } }) } delete() { return new D1FilterBuilder<T>({ url: this.url, headers: this.headers, fetch: this.fetch, method: "DELETE" }) } } class D1FilterBuilder< T extends Table, ResultType = ParserError<"You must chain `select` to your query to receive results"> > { url: URL headers: HeadersInit fetch: Fetch method: "GET" | "POST" | "PATCH" | "DELETE" body?: any constructor({ url, headers, fetch, method, body }: { url: URL headers: HeadersInit fetch: Fetch method: "GET" | "POST" | "PATCH" | "DELETE" body?: any }) { this.url = new URL(url) this.headers = { ...headers } if (method !== "GET") { if (this.headers instanceof Headers) this.headers.set("Content-Type", "application/json") else if (Array.isArray(this.headers)) this.headers.push(["Content-Type", "application/json"]) else this.headers["Content-Type"] = "application/json" } this.fetch = fetch this.method = method this.body = body } eq<Col extends keyof T>(column: Col, value: T[Col]) { this.url.searchParams.append(column as string, `eq.${value}`) return this } neq<Col extends keyof T>(column: Col, value: T[Col]) { this.url.searchParams.append(column as string, `neq.${value}`) return this } gt<Col extends keyof T>(column: Col, value: T[Col]) { this.url.searchParams.append(column as string, `gt.${value}`) return this } lt<Col extends keyof T>(column: Col, value: T[Col]) { this.url.searchParams.append(column as string, `lt.${value}`) return this } like<Col extends keyof T>(column: Col, pattern: string) { this.url.searchParams.append(column as string, `like.${pattern}`) return this } in<Col extends keyof T>(column: Col, values: T[Col][]) { this.url.searchParams.append(column as string, `in.(${values.join(",")})`) return this } order(column: keyof T, { ascending = true } = {}) { this.url.searchParams.append("order", `${column as string}.${ascending ? "asc" : "desc"}`) return this } limit(count: number) { this.url.searchParams.append("limit", count.toString()) return this } offset(count: number) { this.url.searchParams.append("offset", count.toString()) return this } select<Query extends string = "*", RT = Query extends "*" ? T : ExtractValues<T, Query>>( query: Query = "*" as Query ) { this.url.searchParams.set("select", query) return this as unknown as D1FilterBuilder<T, RT> } then(resolve: (value: Required<ResultType>[]) => void, reject?: (reason?: any) => void) { return this.fetch(this.url.toString(), { method: this.method, headers: this.headers, credentials: "include", body: this.body ? JSON.stringify(this.body) : undefined }) .then(async (res) => { if (res.status === 204 || res.status === 304) return [] else if (!res.ok) throw new Error(await res.text()) return res.json() }) .then(resolve, reject) } }