@fine-dev/fine-js
Version:
Javascript client for Fine BaaS
349 lines (306 loc) • 11.5 kB
text/typescript
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)
}
}