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.
@@ -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>>