bunderstack-query 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/package.json +55 -0
- package/src/bucket-client.ts +215 -0
- package/src/errors.ts +18 -0
- package/src/index.ts +248 -0
- package/src/infer.ts +81 -0
- package/src/lazy-client.ts +136 -0
- package/src/mutation-options.ts +73 -0
- package/src/react.tsx +16 -0
- package/src/realtime-client.ts +251 -0
- package/src/table-client.ts +145 -0
- package/src/types.ts +103 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { AnyRouter } from '@trpc/server'
|
|
2
|
+
import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query'
|
|
3
|
+
|
|
4
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
5
|
+
import { createTRPCClient, httpBatchLink } from '@trpc/client'
|
|
6
|
+
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
|
|
7
|
+
import superjson from 'superjson'
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AnyBunderstackApp,
|
|
11
|
+
InferBuckets,
|
|
12
|
+
InferSchema,
|
|
13
|
+
InferTables,
|
|
14
|
+
InferTrpcRouter,
|
|
15
|
+
} from './infer'
|
|
16
|
+
import type { FilesQueryClient, TableQueryOptionsForKey } from './types'
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
attachBucketMutationOptions,
|
|
20
|
+
createBucketClient,
|
|
21
|
+
} from './bucket-client'
|
|
22
|
+
import { attachMutationOptions } from './mutation-options'
|
|
23
|
+
import { createTableClient } from './table-client'
|
|
24
|
+
|
|
25
|
+
export type ClientOptions = {
|
|
26
|
+
baseUrl?: string
|
|
27
|
+
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
28
|
+
queryClient?: QueryClient
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type BunderstackClient<TApp extends AnyBunderstackApp> = {
|
|
32
|
+
[K in InferTables<TApp>]: TableQueryOptionsForKey<InferSchema<TApp>, K>
|
|
33
|
+
} & FilesQueryClient<InferBuckets<TApp>> &
|
|
34
|
+
([InferTrpcRouter<TApp>] extends [never]
|
|
35
|
+
? unknown
|
|
36
|
+
: { trpc: TRPCOptionsProxy<InferTrpcRouter<TApp>> })
|
|
37
|
+
|
|
38
|
+
/** Props a lazy Proxy must not materialize (await/introspection probes). */
|
|
39
|
+
const PROXY_SKIP = new Set<string>([
|
|
40
|
+
'then',
|
|
41
|
+
'toJSON',
|
|
42
|
+
'constructor',
|
|
43
|
+
'$$typeof',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record whose values are created on first property access and cached, so
|
|
48
|
+
* repeated reads return the same instance. Keys are open-ended — the caller's
|
|
49
|
+
* type layer constrains which keys are valid. Note `Object.keys()` on the
|
|
50
|
+
* result is empty by design.
|
|
51
|
+
*/
|
|
52
|
+
export function lazyRecord<T>(create: (key: string) => T): Record<string, T> {
|
|
53
|
+
const cache = new Map<string, T>()
|
|
54
|
+
return new Proxy({} as Record<string, T>, {
|
|
55
|
+
get(_target, prop) {
|
|
56
|
+
if (typeof prop !== 'string' || PROXY_SKIP.has(prop)) return undefined
|
|
57
|
+
let value = cache.get(prop)
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
value = create(prop)
|
|
60
|
+
cache.set(prop, value)
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
},
|
|
64
|
+
has(_target, prop) {
|
|
65
|
+
return typeof prop === 'string' && !PROXY_SKIP.has(prop)
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fully typed client inferred from the server app — tables and buckets come
|
|
72
|
+
* from `typeof app`, materialized lazily on first property access. No
|
|
73
|
+
* runtime table/bucket lists, and no server code in the bundle (the app is
|
|
74
|
+
* referenced as a type only).
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* import type { App } from './bunderstack' // type-only import
|
|
78
|
+
* const api = createClient<App>({ queryClient })
|
|
79
|
+
* api.posts.list(); api.files.images.upload(file)
|
|
80
|
+
*/
|
|
81
|
+
export function createClient<TApp extends AnyBunderstackApp>(
|
|
82
|
+
options: ClientOptions = {},
|
|
83
|
+
): BunderstackClient<TApp> {
|
|
84
|
+
const baseUrl = options.baseUrl ?? '/api'
|
|
85
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
86
|
+
|
|
87
|
+
const files = lazyRecord((bucket) => {
|
|
88
|
+
const bucketClient = createBucketClient({ bucket, baseUrl, fetch: fetchFn })
|
|
89
|
+
return {
|
|
90
|
+
...bucketClient,
|
|
91
|
+
...attachBucketMutationOptions(bucketClient, options.queryClient),
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const tables = lazyRecord((tableName) => {
|
|
96
|
+
const tableClient = createTableClient({
|
|
97
|
+
tableName,
|
|
98
|
+
baseUrl,
|
|
99
|
+
fetch: fetchFn,
|
|
100
|
+
})
|
|
101
|
+
return {
|
|
102
|
+
...tableClient,
|
|
103
|
+
...attachMutationOptions(tableClient, options.queryClient),
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Lazily built so apps without a trpc router pay nothing.
|
|
108
|
+
let trpcProxy: unknown
|
|
109
|
+
const getTrpc = () => {
|
|
110
|
+
trpcProxy ??= createTRPCOptionsProxy({
|
|
111
|
+
client: createTRPCClient<AnyRouter>({
|
|
112
|
+
links: [
|
|
113
|
+
httpBatchLink({
|
|
114
|
+
url: `${baseUrl}/trpc`,
|
|
115
|
+
transformer: superjson,
|
|
116
|
+
fetch: fetchFn,
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
queryClient: options.queryClient ?? new QueryClient(),
|
|
121
|
+
})
|
|
122
|
+
return trpcProxy
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return new Proxy({} as BunderstackClient<TApp>, {
|
|
126
|
+
get(_target, prop) {
|
|
127
|
+
if (typeof prop !== 'string' || PROXY_SKIP.has(prop)) return undefined
|
|
128
|
+
if (prop === 'files') return files
|
|
129
|
+
if (prop === 'trpc') return getTrpc()
|
|
130
|
+
return (tables as Record<string, unknown>)[prop]
|
|
131
|
+
},
|
|
132
|
+
has(_target, prop) {
|
|
133
|
+
return typeof prop === 'string' && !PROXY_SKIP.has(prop)
|
|
134
|
+
},
|
|
135
|
+
}) as BunderstackClient<TApp>
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { QueryClient, UseMutationOptions } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import type { TableClient } from './table-client'
|
|
4
|
+
|
|
5
|
+
export type TableMutationOptions<TRow, TCreate, TUpdate> = {
|
|
6
|
+
createMutation: (
|
|
7
|
+
options?: Omit<
|
|
8
|
+
UseMutationOptions<TRow, Error, Partial<TCreate>>,
|
|
9
|
+
'mutationFn'
|
|
10
|
+
>,
|
|
11
|
+
) => UseMutationOptions<TRow, Error, Partial<TCreate>>
|
|
12
|
+
updateMutation: (
|
|
13
|
+
options?: Omit<
|
|
14
|
+
UseMutationOptions<TRow, Error, { id: string | number; data: TUpdate }>,
|
|
15
|
+
'mutationFn'
|
|
16
|
+
>,
|
|
17
|
+
) => UseMutationOptions<TRow, Error, { id: string | number; data: TUpdate }>
|
|
18
|
+
deleteMutation: (
|
|
19
|
+
options?: Omit<
|
|
20
|
+
UseMutationOptions<void, Error, string | number>,
|
|
21
|
+
'mutationFn'
|
|
22
|
+
>,
|
|
23
|
+
) => UseMutationOptions<void, Error, string | number>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachMutationOptions<TRow, TCreate, TUpdate>(
|
|
27
|
+
table: TableClient<TRow, TCreate, TUpdate>,
|
|
28
|
+
queryClient?: QueryClient,
|
|
29
|
+
): TableMutationOptions<TRow, TCreate, TUpdate> {
|
|
30
|
+
const invalidate = () => {
|
|
31
|
+
if (queryClient)
|
|
32
|
+
void queryClient.invalidateQueries({ queryKey: table.keys.all })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
createMutation(options = {}) {
|
|
37
|
+
const { onSuccess, ...rest } = options
|
|
38
|
+
return {
|
|
39
|
+
mutationFn: (data: Partial<TCreate>) => table.create(data),
|
|
40
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
41
|
+
invalidate()
|
|
42
|
+
onSuccess?.(data, variables, context, mutation)
|
|
43
|
+
},
|
|
44
|
+
...rest,
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
updateMutation(options = {}) {
|
|
48
|
+
const { onSuccess, ...rest } = options
|
|
49
|
+
return {
|
|
50
|
+
mutationFn: ({ id, data }: { id: string | number; data: TUpdate }) =>
|
|
51
|
+
table.update(id, data),
|
|
52
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
53
|
+
invalidate()
|
|
54
|
+
if (queryClient)
|
|
55
|
+
void queryClient.setQueryData(table.keys.detail(variables.id), data)
|
|
56
|
+
onSuccess?.(data, variables, context, mutation)
|
|
57
|
+
},
|
|
58
|
+
...rest,
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
deleteMutation(options = {}) {
|
|
62
|
+
const { onSuccess, ...rest } = options
|
|
63
|
+
return {
|
|
64
|
+
mutationFn: (id: string | number) => table.delete(id),
|
|
65
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
66
|
+
invalidate()
|
|
67
|
+
onSuccess?.(data, variables, context, mutation)
|
|
68
|
+
},
|
|
69
|
+
...rest,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Import from `bunderstack-query` directly — `createBunderstackQueryClient` accepts `queryClient`.
|
|
3
|
+
*/
|
|
4
|
+
export {
|
|
5
|
+
createBunderstackQueryClient,
|
|
6
|
+
createBunderstackQueryClient as createBunderstackReactQueryClient,
|
|
7
|
+
BunderstackApiError,
|
|
8
|
+
} from './index'
|
|
9
|
+
export type {
|
|
10
|
+
BunderstackQueryClient,
|
|
11
|
+
BunderstackQueryClient as BunderstackReactQueryClient,
|
|
12
|
+
Paginated,
|
|
13
|
+
ListParams,
|
|
14
|
+
InferSelect,
|
|
15
|
+
InferInsert,
|
|
16
|
+
} from './types'
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/query-core'
|
|
2
|
+
|
|
3
|
+
import { createTableClient } from './table-client'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Transport choice: custom fetch + ReadableStream SSE reader (NOT EventSource).
|
|
7
|
+
//
|
|
8
|
+
// Why not native EventSource?
|
|
9
|
+
// - Its reconnect is opaque and browser-throttled. Backgrounded/throttled tabs
|
|
10
|
+
// are exactly where it stalls — the failure we are fixing (tab stops updating
|
|
11
|
+
// until you interact with it).
|
|
12
|
+
// - We cannot run our own heartbeat watchdog, backoff, or force a reconnect on
|
|
13
|
+
// `visibilitychange` with EventSource.
|
|
14
|
+
//
|
|
15
|
+
// Why the custom reader?
|
|
16
|
+
// - We own reconnect (explicit backoff), a heartbeat watchdog (no bytes within
|
|
17
|
+
// ~1.5x keepalive => tear down + reconnect), and visibility-driven reconnect.
|
|
18
|
+
//
|
|
19
|
+
// Cost / when to reconsider: more code than `new EventSource(url)`, and we must
|
|
20
|
+
// parse SSE frames ourselves. If real-world testing shows native EventSource is
|
|
21
|
+
// adequate, the previous implementation was: `new EventSource(`${root}/realtime`,
|
|
22
|
+
// { withCredentials: true })` with `es.onmessage` doing the same `apply()` and a
|
|
23
|
+
// `postSubscribe` on the connect frame. Swap the connect loop below back to that.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export type RealtimeEvent = {
|
|
27
|
+
eventId: number
|
|
28
|
+
action: 'create' | 'update' | 'delete'
|
|
29
|
+
table: string
|
|
30
|
+
record: Record<string, unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RealtimeStatus = 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
34
|
+
|
|
35
|
+
export type RealtimeClientConfig = {
|
|
36
|
+
baseUrl: string
|
|
37
|
+
queryClient: QueryClient
|
|
38
|
+
tables: string[]
|
|
39
|
+
fetch?: typeof fetch
|
|
40
|
+
/**
|
|
41
|
+
* How often the server sends a keepalive ping (milliseconds). Defaults to 30 000.
|
|
42
|
+
*
|
|
43
|
+
* INVARIANT: this value MUST be >= the server's `realtime.keepaliveMs`.
|
|
44
|
+
* The client watchdog fires at ~1.5× keepaliveMs; if this is set lower than
|
|
45
|
+
* the server's interval, the watchdog reconnects before the server's ping
|
|
46
|
+
* arrives and causes a reconnect storm.
|
|
47
|
+
*/
|
|
48
|
+
keepaliveMs?: number
|
|
49
|
+
onStatus?: (s: RealtimeStatus) => void
|
|
50
|
+
/**
|
|
51
|
+
* Override how an event is applied to local state. Defaults to patching
|
|
52
|
+
* the TanStack Query cache (setQueryData on detail key + invalidateQueries
|
|
53
|
+
* on the list key). Set this to integrate with a different local store
|
|
54
|
+
* (e.g. a TanStack DB collection's direct-write API).
|
|
55
|
+
*/
|
|
56
|
+
applyEvent?: (evt: RealtimeEvent) => void
|
|
57
|
+
/**
|
|
58
|
+
* Override full-resync-on-gap behavior. Defaults to invalidating every
|
|
59
|
+
* subscribed table's list query.
|
|
60
|
+
*/
|
|
61
|
+
onGap?: () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createRealtimeClient(config: RealtimeClientConfig) {
|
|
65
|
+
const { baseUrl, queryClient, tables } = config
|
|
66
|
+
const fetchFn = config.fetch ?? fetch
|
|
67
|
+
const keepaliveMs = config.keepaliveMs ?? 30000
|
|
68
|
+
const root = baseUrl.replace(/\/$/, '')
|
|
69
|
+
|
|
70
|
+
const keysByTable = new Map(
|
|
71
|
+
tables.map((t) => [
|
|
72
|
+
t,
|
|
73
|
+
createTableClient({ tableName: t, baseUrl: root, fetch: fetchFn }).keys,
|
|
74
|
+
]),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
let clientId: string | null = null
|
|
78
|
+
let lastTopics: string[] = []
|
|
79
|
+
let lastEventId: number | null = null
|
|
80
|
+
let closed = false
|
|
81
|
+
let abort: AbortController | null = null
|
|
82
|
+
let backoff = 1000
|
|
83
|
+
let watchdog: ReturnType<typeof setTimeout> | null = null
|
|
84
|
+
let backoffTimer: ReturnType<typeof setTimeout> | null = null
|
|
85
|
+
|
|
86
|
+
function setStatus(s: RealtimeStatus) {
|
|
87
|
+
config.onStatus?.(s)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function apply(evt: RealtimeEvent) {
|
|
91
|
+
if (typeof evt.eventId === 'number') lastEventId = evt.eventId
|
|
92
|
+
// A custom applyEvent owns routing entirely — lazy clients can't
|
|
93
|
+
// enumerate tables upfront, so don't gate on the static list.
|
|
94
|
+
if (config.applyEvent) {
|
|
95
|
+
config.applyEvent(evt)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const keys = keysByTable.get(evt.table)
|
|
99
|
+
if (!keys) return
|
|
100
|
+
const id = evt.record['id'] as string | number
|
|
101
|
+
if (evt.action === 'delete')
|
|
102
|
+
queryClient.removeQueries({ queryKey: keys.detail(id) })
|
|
103
|
+
else queryClient.setQueryData(keys.detail(id), evt.record)
|
|
104
|
+
queryClient.invalidateQueries({ queryKey: keys.lists() })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function invalidateAllSubscribed() {
|
|
108
|
+
if (config.onGap) {
|
|
109
|
+
config.onGap()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
for (const t of tables) {
|
|
113
|
+
const keys = keysByTable.get(t)
|
|
114
|
+
if (keys) queryClient.invalidateQueries({ queryKey: keys.lists() })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function postSubscribe(topics: string[]) {
|
|
119
|
+
if (!clientId) return
|
|
120
|
+
const res = await fetchFn(`${root}/realtime`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
credentials: 'include',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
clientId,
|
|
126
|
+
subscriptions: topics,
|
|
127
|
+
since: lastEventId,
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
const body = (await res.json().catch(() => ({}))) as { gap?: boolean }
|
|
131
|
+
if (body.gap) invalidateAllSubscribed()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function armWatchdog() {
|
|
135
|
+
if (watchdog) clearTimeout(watchdog)
|
|
136
|
+
// No bytes (event or `: ping`) within 1.5x keepalive => assume dead, reconnect.
|
|
137
|
+
watchdog = setTimeout(
|
|
138
|
+
() => {
|
|
139
|
+
abort?.abort()
|
|
140
|
+
},
|
|
141
|
+
Math.round(keepaliveMs * 1.5),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleFrame(frame: string) {
|
|
146
|
+
armWatchdog()
|
|
147
|
+
// Comment lines (": ping") are liveness only.
|
|
148
|
+
const dataLines = frame.split('\n').filter((l) => l.startsWith('data:'))
|
|
149
|
+
if (!dataLines.length) return
|
|
150
|
+
const json = dataLines.map((l) => l.slice(5).trim()).join('\n')
|
|
151
|
+
let data: unknown
|
|
152
|
+
try {
|
|
153
|
+
data = JSON.parse(json)
|
|
154
|
+
} catch {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
data &&
|
|
159
|
+
typeof data === 'object' &&
|
|
160
|
+
'clientId' in data &&
|
|
161
|
+
(data as any).clientId
|
|
162
|
+
) {
|
|
163
|
+
clientId = (data as any).clientId
|
|
164
|
+
if (lastTopics.length) void postSubscribe(lastTopics)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
apply(data as RealtimeEvent)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function connectLoop() {
|
|
171
|
+
while (!closed) {
|
|
172
|
+
abort = new AbortController()
|
|
173
|
+
setStatus(clientId ? 'reconnecting' : 'connecting')
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetchFn(`${root}/realtime`, {
|
|
176
|
+
credentials: 'include',
|
|
177
|
+
headers: { Accept: 'text/event-stream' },
|
|
178
|
+
signal: abort.signal,
|
|
179
|
+
})
|
|
180
|
+
if (!res.body) throw new Error('no body')
|
|
181
|
+
setStatus('open')
|
|
182
|
+
backoff = 1000
|
|
183
|
+
armWatchdog()
|
|
184
|
+
const reader = res.body.getReader()
|
|
185
|
+
const decoder = new TextDecoder()
|
|
186
|
+
let buf = ''
|
|
187
|
+
for (;;) {
|
|
188
|
+
const { value, done } = await reader.read()
|
|
189
|
+
if (done) break
|
|
190
|
+
buf += decoder.decode(value, { stream: true })
|
|
191
|
+
let idx: number
|
|
192
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
193
|
+
const frame = buf.slice(0, idx)
|
|
194
|
+
buf = buf.slice(idx + 2)
|
|
195
|
+
handleFrame(frame)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
/* fallthrough to reconnect */
|
|
200
|
+
}
|
|
201
|
+
if (watchdog) {
|
|
202
|
+
clearTimeout(watchdog)
|
|
203
|
+
watchdog = null
|
|
204
|
+
}
|
|
205
|
+
if (closed) break
|
|
206
|
+
// Reconnect with jittered backoff (cap 30s). lastEventId drives replay.
|
|
207
|
+
const wait = Math.min(backoff, 30000) * (0.5 + Math.random())
|
|
208
|
+
backoff = Math.min(backoff * 2, 30000)
|
|
209
|
+
await new Promise<void>((r) => {
|
|
210
|
+
backoffTimer = setTimeout(r, wait)
|
|
211
|
+
})
|
|
212
|
+
backoffTimer = null
|
|
213
|
+
}
|
|
214
|
+
setStatus('closed')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Refocus => force an immediate reconnect + catch-up (no waiting on backoff).
|
|
218
|
+
const onVisible = () => {
|
|
219
|
+
if (closed) return
|
|
220
|
+
if (document.visibilityState === 'visible') {
|
|
221
|
+
backoff = 1000
|
|
222
|
+
abort?.abort()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (typeof document !== 'undefined')
|
|
226
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
227
|
+
|
|
228
|
+
void connectLoop()
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
async subscribe(topics: string[]) {
|
|
232
|
+
lastTopics = topics
|
|
233
|
+
await postSubscribe(topics)
|
|
234
|
+
},
|
|
235
|
+
close() {
|
|
236
|
+
closed = true
|
|
237
|
+
if (watchdog) {
|
|
238
|
+
clearTimeout(watchdog)
|
|
239
|
+
watchdog = null
|
|
240
|
+
}
|
|
241
|
+
if (backoffTimer) {
|
|
242
|
+
clearTimeout(backoffTimer)
|
|
243
|
+
backoffTimer = null
|
|
244
|
+
}
|
|
245
|
+
if (typeof document !== 'undefined')
|
|
246
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
247
|
+
abort?.abort()
|
|
248
|
+
setStatus('closed')
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ListParams, Paginated } from './types'
|
|
2
|
+
|
|
3
|
+
import { BunderstackApiError } from './errors'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-side cap on any single list request — mirrors MAX_LIST_LIMIT in
|
|
7
|
+
* packages/bunderstack/src/list-query.ts (parity enforced by test).
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_LIST_LIMIT = 200
|
|
10
|
+
|
|
11
|
+
const RESERVED_LIST_PARAMS = new Set([
|
|
12
|
+
'limit',
|
|
13
|
+
'offset',
|
|
14
|
+
'sort',
|
|
15
|
+
'order',
|
|
16
|
+
'q',
|
|
17
|
+
'cursor',
|
|
18
|
+
'count',
|
|
19
|
+
'cursorMode',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
export type TableClientConfig = {
|
|
23
|
+
tableName: string
|
|
24
|
+
baseUrl: string
|
|
25
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function parseError(res: Response): Promise<BunderstackApiError> {
|
|
29
|
+
const body = await res.json().catch(() => ({}))
|
|
30
|
+
const message =
|
|
31
|
+
typeof body === 'object' &&
|
|
32
|
+
body !== null &&
|
|
33
|
+
'error' in body &&
|
|
34
|
+
typeof body.error === 'string'
|
|
35
|
+
? body.error
|
|
36
|
+
: `Request failed (${res.status})`
|
|
37
|
+
return new BunderstackApiError(message, res.status, body)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createTableClient<
|
|
41
|
+
TRow,
|
|
42
|
+
TCreate = Partial<TRow>,
|
|
43
|
+
TUpdate = Partial<TRow>,
|
|
44
|
+
>(config: TableClientConfig) {
|
|
45
|
+
const { tableName, baseUrl, fetch: fetchFn } = config
|
|
46
|
+
const root = `${baseUrl.replace(/\/$/, '')}/${tableName}`
|
|
47
|
+
|
|
48
|
+
const keys = {
|
|
49
|
+
all: [tableName] as const,
|
|
50
|
+
lists: () => [tableName, 'list'] as const,
|
|
51
|
+
list: (params: ListParams) => [tableName, 'list', params] as const,
|
|
52
|
+
details: () => [tableName, 'detail'] as const,
|
|
53
|
+
detail: (id: string | number) => [tableName, 'detail', id] as const,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
57
|
+
const res = await fetchFn(`${root}${path}`, {
|
|
58
|
+
credentials: 'include',
|
|
59
|
+
...init,
|
|
60
|
+
headers: {
|
|
61
|
+
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
|
62
|
+
...init?.headers,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!res.ok) throw await parseError(res)
|
|
67
|
+
if (res.status === 204) return undefined as T
|
|
68
|
+
return res.json() as Promise<T>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const list = (params: ListParams = {}) => {
|
|
72
|
+
const qs = new URLSearchParams()
|
|
73
|
+
|
|
74
|
+
if (params.limit !== undefined) qs.set('limit', String(params.limit))
|
|
75
|
+
if (params.offset !== undefined) qs.set('offset', String(params.offset))
|
|
76
|
+
if (params.sort) qs.set('sort', params.sort)
|
|
77
|
+
if (params.order) qs.set('order', params.order)
|
|
78
|
+
if (params.q?.trim()) qs.set('q', params.q.trim())
|
|
79
|
+
if (params.cursor) qs.set('cursor', params.cursor)
|
|
80
|
+
if (params.count) qs.set('count', 'true')
|
|
81
|
+
|
|
82
|
+
for (const [key, value] of Object.entries(params)) {
|
|
83
|
+
if (RESERVED_LIST_PARAMS.has(key)) continue
|
|
84
|
+
if (value === undefined) continue
|
|
85
|
+
qs.set(key, value === null ? 'null' : String(value))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!qs.has('limit')) qs.set('limit', '20')
|
|
89
|
+
if (!qs.has('offset') && !qs.has('cursor') && !params.cursorMode) {
|
|
90
|
+
qs.set('offset', '0')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return request<Paginated<TRow>>(`?${qs}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const listInfiniteQuery = (params: ListParams = {}) => {
|
|
97
|
+
const {
|
|
98
|
+
offset: _offset,
|
|
99
|
+
cursor: _cursor,
|
|
100
|
+
cursorMode: _cursorMode,
|
|
101
|
+
...base
|
|
102
|
+
} = params
|
|
103
|
+
return {
|
|
104
|
+
queryKey: [...keys.list(params), 'infinite'] as const,
|
|
105
|
+
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
|
106
|
+
list({
|
|
107
|
+
...base,
|
|
108
|
+
cursorMode: true,
|
|
109
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
110
|
+
}),
|
|
111
|
+
initialPageParam: undefined as string | undefined,
|
|
112
|
+
getNextPageParam: (lastPage: Paginated<TRow>) =>
|
|
113
|
+
lastPage.nextCursor ?? undefined,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const get = (id: string | number) => request<TRow>(`/${id}`)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
keys,
|
|
121
|
+
list,
|
|
122
|
+
get,
|
|
123
|
+
create: (data: Partial<TCreate>) =>
|
|
124
|
+
request<TRow>('', { method: 'POST', body: JSON.stringify(data) }),
|
|
125
|
+
update: (id: string | number, data: TUpdate) =>
|
|
126
|
+
request<TRow>(`/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
127
|
+
delete: (id: string | number) =>
|
|
128
|
+
request<void>(`/${id}`, { method: 'DELETE' }),
|
|
129
|
+
listQuery: (params: ListParams = {}) => ({
|
|
130
|
+
queryKey: keys.list(params),
|
|
131
|
+
queryFn: () => list(params),
|
|
132
|
+
}),
|
|
133
|
+
listInfiniteQuery,
|
|
134
|
+
getQuery: (id: string | number) => ({
|
|
135
|
+
queryKey: keys.detail(id),
|
|
136
|
+
queryFn: () => get(id),
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type TableClient<
|
|
142
|
+
TRow,
|
|
143
|
+
TCreate = Partial<TRow>,
|
|
144
|
+
TUpdate = Partial<TRow>,
|
|
145
|
+
> = ReturnType<typeof createTableClient<TRow, TCreate, TUpdate>>
|