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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kirill Chuprov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# bunderstack-query
|
|
2
|
+
|
|
3
|
+
Typed client for [bunderstack](https://github.com/kirill-dev-pro/bunderstack)
|
|
4
|
+
backends: tRPC client, TanStack Query option factories, realtime
|
|
5
|
+
subscriptions, and React hooks.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add bunderstack-query
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createClient } from 'bunderstack-query'
|
|
13
|
+
import type { App } from '../server/app'
|
|
14
|
+
|
|
15
|
+
const client = createClient<App>({ baseUrl: '/api' })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Full documentation and examples:
|
|
19
|
+
[github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
|
|
20
|
+
|
|
21
|
+
## Shipping TypeScript source
|
|
22
|
+
|
|
23
|
+
This package publishes raw TypeScript (`exports` point at `.ts`/`.tsx`
|
|
24
|
+
files). Bun consumes it natively. If a Node-based bundler or SSR server
|
|
25
|
+
processes it, make sure the package is bundled rather than externalized —
|
|
26
|
+
e.g. in Vite:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
ssr: {
|
|
30
|
+
noExternal: [/^bunderstack/]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bunderstack-query",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed client for bunderstack backends: tRPC client, TanStack Query option factories, realtime subscriptions, and React hooks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"bunderstack",
|
|
8
|
+
"client",
|
|
9
|
+
"react",
|
|
10
|
+
"realtime",
|
|
11
|
+
"tanstack-query",
|
|
12
|
+
"trpc"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/kirill-dev-pro/bunderstack#readme",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kirill-dev-pro/bunderstack.git",
|
|
19
|
+
"directory": "packages/bunderstack-query"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"!src/**/*.test.ts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"module": "./src/index.ts",
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts",
|
|
33
|
+
"./react": "./src/react.tsx"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "bun test"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@trpc/client": "^11.0.0",
|
|
40
|
+
"@trpc/server": "^11.0.0",
|
|
41
|
+
"@trpc/tanstack-react-query": "^11.0.0",
|
|
42
|
+
"bunderstack": "0.1.0",
|
|
43
|
+
"superjson": "^2.2.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@tanstack/query-core": "^5.101.1",
|
|
47
|
+
"@tanstack/react-query": "^5.101.1",
|
|
48
|
+
"@types/bun": "^1.3.14",
|
|
49
|
+
"zod": "^4.4.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@tanstack/react-query": "^5.101.0",
|
|
53
|
+
"typescript": "^5"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { QueryClient, UseMutationOptions } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import { BunderstackApiError } from './errors'
|
|
4
|
+
|
|
5
|
+
export type UploadedFile = {
|
|
6
|
+
fileId: string
|
|
7
|
+
url: string
|
|
8
|
+
name: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type UploadMode = 'auto' | 'proxy' | 'presign'
|
|
12
|
+
|
|
13
|
+
export type UploadOptions = {
|
|
14
|
+
mode?: UploadMode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type FileTransformOptions = {
|
|
18
|
+
w?: number
|
|
19
|
+
h?: number
|
|
20
|
+
format?: 'webp' | 'jpeg' | 'png' | 'avif'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type BucketClientConfig = {
|
|
24
|
+
bucket: string
|
|
25
|
+
baseUrl: string
|
|
26
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PresignResponse =
|
|
30
|
+
| {
|
|
31
|
+
mode: 'proxy'
|
|
32
|
+
uploadUrl: string
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
mode: 'presign'
|
|
36
|
+
fileId: string
|
|
37
|
+
uploadUrl: string
|
|
38
|
+
method?: string
|
|
39
|
+
confirmUrl: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function parseError(res: Response): Promise<BunderstackApiError> {
|
|
43
|
+
const body = await res.json().catch(() => ({}))
|
|
44
|
+
const message =
|
|
45
|
+
body &&
|
|
46
|
+
typeof body === 'object' &&
|
|
47
|
+
'error' in body &&
|
|
48
|
+
typeof body.error === 'string'
|
|
49
|
+
? body.error
|
|
50
|
+
: `Request failed (${res.status})`
|
|
51
|
+
return new BunderstackApiError(message, res.status, body)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readJson<T>(res: Response): Promise<T> {
|
|
55
|
+
if (!res.ok) throw await parseError(res)
|
|
56
|
+
return res.json() as Promise<T>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function trimBaseUrl(baseUrl: string): string {
|
|
60
|
+
return baseUrl.replace(/\/$/, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativeId(bucket: string, idOrFileId: string): string {
|
|
64
|
+
const prefix = `${bucket}/`
|
|
65
|
+
return idOrFileId.startsWith(prefix)
|
|
66
|
+
? idOrFileId.slice(prefix.length)
|
|
67
|
+
: idOrFileId
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function encodeFilePath(idOrFileId: string): string {
|
|
71
|
+
return idOrFileId
|
|
72
|
+
.split('/')
|
|
73
|
+
.map((segment) =>
|
|
74
|
+
encodeURIComponent(decodeURIComponent(segment)).replace(/\./g, '%2E'),
|
|
75
|
+
)
|
|
76
|
+
.join('/')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fileUrl(root: string, bucket: string, idOrFileId: string): string {
|
|
80
|
+
return `${root}/${encodeFilePath(relativeId(bucket, idOrFileId))}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contentTypeHeader(file: File): HeadersInit | undefined {
|
|
84
|
+
return file.type ? { 'Content-Type': file.type } : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createBucketClient(config: BucketClientConfig) {
|
|
88
|
+
const { bucket, fetch: fetchFn } = config
|
|
89
|
+
const apiRoot = `${trimBaseUrl(config.baseUrl)}/files/${bucket}`
|
|
90
|
+
const keys = {
|
|
91
|
+
all: ['files', bucket] as const,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function proxyUpload(file: File): Promise<UploadedFile> {
|
|
95
|
+
const body = new FormData()
|
|
96
|
+
body.append('file', file)
|
|
97
|
+
const res = await fetchFn(apiRoot, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
body,
|
|
100
|
+
credentials: 'include',
|
|
101
|
+
})
|
|
102
|
+
const uploaded = await readJson<{ fileId: string; url: string }>(res)
|
|
103
|
+
return { ...uploaded, url: url(uploaded.fileId), name: file.name }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function presignUpload(file: File): Promise<UploadedFile> {
|
|
107
|
+
const presignRes = await fetchFn(`${apiRoot}/presign`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
filename: file.name,
|
|
111
|
+
contentType: file.type || undefined,
|
|
112
|
+
}),
|
|
113
|
+
credentials: 'include',
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
})
|
|
116
|
+
const presign = await readJson<PresignResponse>(presignRes)
|
|
117
|
+
if (presign.mode === 'proxy') return proxyUpload(file)
|
|
118
|
+
|
|
119
|
+
const uploadRes = await fetchFn(presign.uploadUrl, {
|
|
120
|
+
method: presign.method ?? 'PUT',
|
|
121
|
+
body: file,
|
|
122
|
+
headers: contentTypeHeader(file),
|
|
123
|
+
})
|
|
124
|
+
if (!uploadRes.ok) throw await parseError(uploadRes)
|
|
125
|
+
|
|
126
|
+
const confirmRes = await fetchFn(`${url(presign.fileId)}/confirm`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
credentials: 'include',
|
|
129
|
+
})
|
|
130
|
+
const uploaded = await readJson<{ fileId: string; url: string }>(confirmRes)
|
|
131
|
+
return { ...uploaded, url: url(uploaded.fileId), name: file.name }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const upload = (file: File, options: UploadOptions = {}) =>
|
|
135
|
+
options.mode === 'presign' ? presignUpload(file) : proxyUpload(file)
|
|
136
|
+
|
|
137
|
+
const deleteFile = async (idOrFileId: string): Promise<void> => {
|
|
138
|
+
const res = await fetchFn(fileUrl(apiRoot, bucket, idOrFileId), {
|
|
139
|
+
method: 'DELETE',
|
|
140
|
+
credentials: 'include',
|
|
141
|
+
})
|
|
142
|
+
if (!res.ok) throw await parseError(res)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const url = (
|
|
146
|
+
idOrFileId: string,
|
|
147
|
+
transforms: FileTransformOptions = {},
|
|
148
|
+
): string => {
|
|
149
|
+
const params = new URLSearchParams()
|
|
150
|
+
if (transforms.w !== undefined) params.set('w', String(transforms.w))
|
|
151
|
+
if (transforms.h !== undefined) params.set('h', String(transforms.h))
|
|
152
|
+
if (transforms.format) params.set('format', transforms.format)
|
|
153
|
+
const qs = params.toString()
|
|
154
|
+
return `${fileUrl(apiRoot, bucket, idOrFileId)}${qs ? `?${qs}` : ''}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
keys,
|
|
159
|
+
upload,
|
|
160
|
+
delete: deleteFile,
|
|
161
|
+
url,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type BucketClient = ReturnType<typeof createBucketClient>
|
|
166
|
+
|
|
167
|
+
export type BucketMutationOptions = {
|
|
168
|
+
uploadMutation: (
|
|
169
|
+
options?: Omit<
|
|
170
|
+
UseMutationOptions<UploadedFile, Error, File, unknown>,
|
|
171
|
+
'mutationFn'
|
|
172
|
+
>,
|
|
173
|
+
) => UseMutationOptions<UploadedFile, Error, File, unknown>
|
|
174
|
+
deleteMutation: (
|
|
175
|
+
options?: Omit<
|
|
176
|
+
UseMutationOptions<void, Error, string, unknown>,
|
|
177
|
+
'mutationFn'
|
|
178
|
+
>,
|
|
179
|
+
) => UseMutationOptions<void, Error, string, unknown>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function attachBucketMutationOptions(
|
|
183
|
+
bucketClient: BucketClient,
|
|
184
|
+
queryClient?: QueryClient,
|
|
185
|
+
): BucketMutationOptions {
|
|
186
|
+
const invalidate = () => {
|
|
187
|
+
if (queryClient)
|
|
188
|
+
void queryClient.invalidateQueries({ queryKey: bucketClient.keys.all })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
uploadMutation(options = {}) {
|
|
193
|
+
const { onSuccess, ...rest } = options
|
|
194
|
+
return {
|
|
195
|
+
mutationFn: (file: File) => bucketClient.upload(file),
|
|
196
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
197
|
+
invalidate()
|
|
198
|
+
onSuccess?.(data, variables, context, mutation)
|
|
199
|
+
},
|
|
200
|
+
...rest,
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
deleteMutation(options = {}) {
|
|
204
|
+
const { onSuccess, ...rest } = options
|
|
205
|
+
return {
|
|
206
|
+
mutationFn: (id: string) => bucketClient.delete(id),
|
|
207
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
208
|
+
invalidate()
|
|
209
|
+
onSuccess?.(data, variables, context, mutation)
|
|
210
|
+
},
|
|
211
|
+
...rest,
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class BunderstackApiError extends Error {
|
|
2
|
+
readonly status: number
|
|
3
|
+
readonly body: unknown
|
|
4
|
+
readonly code?: string
|
|
5
|
+
readonly details?: unknown
|
|
6
|
+
|
|
7
|
+
constructor(message: string, status: number, body: unknown = undefined) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'BunderstackApiError'
|
|
10
|
+
this.status = status
|
|
11
|
+
this.body = body
|
|
12
|
+
if (body && typeof body === 'object' && body !== null) {
|
|
13
|
+
const record = body as Record<string, unknown>
|
|
14
|
+
if (typeof record.code === 'string') this.code = record.code
|
|
15
|
+
if ('details' in record) this.details = record.details
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
validateAndResolveAccess,
|
|
5
|
+
type TableAccessInput,
|
|
6
|
+
} from 'bunderstack/access'
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BunderstackQueryClient,
|
|
10
|
+
CrudTableKey,
|
|
11
|
+
FilesQueryClient,
|
|
12
|
+
InferInsert,
|
|
13
|
+
InferSelect,
|
|
14
|
+
TableQueryOptions,
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
attachBucketMutationOptions,
|
|
19
|
+
createBucketClient,
|
|
20
|
+
} from './bucket-client'
|
|
21
|
+
import { attachMutationOptions } from './mutation-options'
|
|
22
|
+
import { createTableClient } from './table-client'
|
|
23
|
+
|
|
24
|
+
type BaseOptions = {
|
|
25
|
+
baseUrl?: string
|
|
26
|
+
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
27
|
+
queryClient?: QueryClient
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildTableQueryOptions<
|
|
31
|
+
TSchema extends Record<string, unknown>,
|
|
32
|
+
K extends keyof TSchema & string,
|
|
33
|
+
>(
|
|
34
|
+
_tableKey: K,
|
|
35
|
+
tableName: string,
|
|
36
|
+
table: TSchema[K] | undefined,
|
|
37
|
+
config: BaseOptions & {
|
|
38
|
+
baseUrl: string
|
|
39
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
40
|
+
},
|
|
41
|
+
): TableQueryOptions<
|
|
42
|
+
InferSelect<NonNullable<typeof table>>,
|
|
43
|
+
InferInsert<NonNullable<typeof table>>,
|
|
44
|
+
Partial<InferSelect<NonNullable<typeof table>>>
|
|
45
|
+
> {
|
|
46
|
+
type Row = InferSelect<NonNullable<typeof table>>
|
|
47
|
+
type Create = InferInsert<NonNullable<typeof table>>
|
|
48
|
+
type Update = Partial<Row>
|
|
49
|
+
|
|
50
|
+
const client = createTableClient<Row, Create, Update>({
|
|
51
|
+
tableName,
|
|
52
|
+
baseUrl: config.baseUrl,
|
|
53
|
+
fetch: config.fetch,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...client,
|
|
58
|
+
...attachMutationOptions(client, config.queryClient),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createBunderstackQueryClient<
|
|
63
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
64
|
+
>() {
|
|
65
|
+
return {
|
|
66
|
+
/**
|
|
67
|
+
* Tables path: provide TSchema explicitly as a generic, TTables is inferred from the tuple.
|
|
68
|
+
* The schema is never imported as a value — safe for client bundles.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* import type * as schema from './schema'
|
|
72
|
+
* const api = createBunderstackQueryClient<typeof schema>()
|
|
73
|
+
* .withTables({ queryClient, tables: ['posts', 'user'] as const })
|
|
74
|
+
*/
|
|
75
|
+
withTables<const TTables extends readonly (keyof TSchema & string)[]>(
|
|
76
|
+
options: BaseOptions & { tables: TTables },
|
|
77
|
+
): BunderstackQueryClient<TSchema, TTables[number]> {
|
|
78
|
+
const baseUrl = options.baseUrl ?? '/api'
|
|
79
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
80
|
+
const client = {} as BunderstackQueryClient<TSchema, TTables[number]>
|
|
81
|
+
|
|
82
|
+
for (const tableKey of options.tables) {
|
|
83
|
+
const tableClient = createTableClient({
|
|
84
|
+
tableName: tableKey,
|
|
85
|
+
baseUrl,
|
|
86
|
+
fetch: fetchFn,
|
|
87
|
+
})
|
|
88
|
+
;(client as Record<string, unknown>)[tableKey] = {
|
|
89
|
+
...tableClient,
|
|
90
|
+
...attachMutationOptions(tableClient, options.queryClient),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return client
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Schema path: TSchema is inferred from the schema value. Exposed tables are determined
|
|
98
|
+
* by access rules (auth tables excluded by default via CrudTableKey).
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* import * as schema from './schema'
|
|
102
|
+
* const api = createBunderstackQueryClient()
|
|
103
|
+
* .withSchema({ schema, queryClient })
|
|
104
|
+
*/
|
|
105
|
+
withSchema<
|
|
106
|
+
S extends TSchema,
|
|
107
|
+
TAccess extends Record<string, TableAccessInput> | undefined = undefined,
|
|
108
|
+
>(
|
|
109
|
+
options: BaseOptions & { schema: S; access?: TAccess },
|
|
110
|
+
): BunderstackQueryClient<S, CrudTableKey<S>> {
|
|
111
|
+
const baseUrl = options.baseUrl ?? '/api'
|
|
112
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
113
|
+
const client = {} as BunderstackQueryClient<S, CrudTableKey<S>>
|
|
114
|
+
const config = {
|
|
115
|
+
baseUrl,
|
|
116
|
+
fetch: fetchFn,
|
|
117
|
+
queryClient: options.queryClient,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolved = validateAndResolveAccess(options.schema, options.access)
|
|
121
|
+
for (const [tableKey, tableAccess] of resolved) {
|
|
122
|
+
if (!tableAccess.enabled) continue
|
|
123
|
+
;(client as Record<string, unknown>)[tableKey] = buildTableQueryOptions(
|
|
124
|
+
tableKey as keyof S & string,
|
|
125
|
+
tableAccess.tableName,
|
|
126
|
+
options.schema[tableKey],
|
|
127
|
+
config,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return client
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
withFiles<const TBuckets extends readonly string[]>(
|
|
134
|
+
options: BaseOptions & { buckets: TBuckets },
|
|
135
|
+
): FilesQueryClient<TBuckets[number]> {
|
|
136
|
+
const baseUrl = options.baseUrl ?? '/api'
|
|
137
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
138
|
+
const client: FilesQueryClient<TBuckets[number]> = {
|
|
139
|
+
files: {} as FilesQueryClient<TBuckets[number]>['files'],
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const bucket of options.buckets) {
|
|
143
|
+
const bucketClient = createBucketClient({
|
|
144
|
+
bucket,
|
|
145
|
+
baseUrl,
|
|
146
|
+
fetch: fetchFn,
|
|
147
|
+
})
|
|
148
|
+
client.files[bucket as TBuckets[number]] = {
|
|
149
|
+
...bucketClient,
|
|
150
|
+
...attachBucketMutationOptions(bucketClient, options.queryClient),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return client
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
with<
|
|
157
|
+
const TTables extends readonly (keyof TSchema & string)[],
|
|
158
|
+
const TBuckets extends readonly string[],
|
|
159
|
+
>(
|
|
160
|
+
options: BaseOptions & { tables: TTables; buckets: TBuckets },
|
|
161
|
+
): BunderstackQueryClient<TSchema, TTables[number]> &
|
|
162
|
+
FilesQueryClient<TBuckets[number]> {
|
|
163
|
+
const baseUrl = options.baseUrl ?? '/api'
|
|
164
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
165
|
+
const tablesClient = {} as BunderstackQueryClient<
|
|
166
|
+
TSchema,
|
|
167
|
+
TTables[number]
|
|
168
|
+
>
|
|
169
|
+
|
|
170
|
+
for (const tableKey of options.tables) {
|
|
171
|
+
const tableClient = createTableClient({
|
|
172
|
+
tableName: tableKey,
|
|
173
|
+
baseUrl,
|
|
174
|
+
fetch: fetchFn,
|
|
175
|
+
})
|
|
176
|
+
;(tablesClient as Record<string, unknown>)[tableKey] = {
|
|
177
|
+
...tableClient,
|
|
178
|
+
...attachMutationOptions(tableClient, options.queryClient),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const filesClient: FilesQueryClient<TBuckets[number]> = {
|
|
183
|
+
files: {} as FilesQueryClient<TBuckets[number]>['files'],
|
|
184
|
+
}
|
|
185
|
+
for (const bucket of options.buckets) {
|
|
186
|
+
const bucketClient = createBucketClient({
|
|
187
|
+
bucket,
|
|
188
|
+
baseUrl,
|
|
189
|
+
fetch: fetchFn,
|
|
190
|
+
})
|
|
191
|
+
filesClient.files[bucket as TBuckets[number]] = {
|
|
192
|
+
...bucketClient,
|
|
193
|
+
...attachBucketMutationOptions(bucketClient, options.queryClient),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...tablesClient,
|
|
199
|
+
...filesClient,
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export { createClient, lazyRecord } from './lazy-client'
|
|
206
|
+
export type { BunderstackClient, ClientOptions } from './lazy-client'
|
|
207
|
+
export { MAX_LIST_LIMIT } from './table-client'
|
|
208
|
+
export type {
|
|
209
|
+
AnyBunderstackApp,
|
|
210
|
+
ClientCarrier,
|
|
211
|
+
ExposedTables,
|
|
212
|
+
InferBuckets,
|
|
213
|
+
InferSchema,
|
|
214
|
+
InferTables,
|
|
215
|
+
InferTrpcRouter,
|
|
216
|
+
} from './infer'
|
|
217
|
+
export { BunderstackApiError } from './errors'
|
|
218
|
+
export type {
|
|
219
|
+
BunderstackQueryClient,
|
|
220
|
+
CreateClientOptions,
|
|
221
|
+
CrudTableKey,
|
|
222
|
+
ExposedTableKeys,
|
|
223
|
+
FilesQueryClient,
|
|
224
|
+
InferInsert,
|
|
225
|
+
InferSelect,
|
|
226
|
+
ListParams,
|
|
227
|
+
Paginated,
|
|
228
|
+
TableQueryOptions,
|
|
229
|
+
TableQueryOptionsForKey,
|
|
230
|
+
UseMutationOptions,
|
|
231
|
+
} from './types'
|
|
232
|
+
export {
|
|
233
|
+
createBucketClient,
|
|
234
|
+
attachBucketMutationOptions,
|
|
235
|
+
} from './bucket-client'
|
|
236
|
+
export type {
|
|
237
|
+
BucketClient,
|
|
238
|
+
BucketClientConfig,
|
|
239
|
+
BucketMutationOptions,
|
|
240
|
+
FileTransformOptions,
|
|
241
|
+
UploadedFile,
|
|
242
|
+
UploadMode,
|
|
243
|
+
UploadOptions,
|
|
244
|
+
} from './bucket-client'
|
|
245
|
+
export { createTableClient } from './table-client'
|
|
246
|
+
export type { TableClient, TableClientConfig } from './table-client'
|
|
247
|
+
export { createRealtimeClient } from './realtime-client'
|
|
248
|
+
export type { RealtimeClientConfig, RealtimeEvent } from './realtime-client'
|
package/src/infer.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AnyRouter } from '@trpc/server'
|
|
2
|
+
|
|
3
|
+
import type { AuthTableName, CrudTableKey, InferSelect } from './types'
|
|
4
|
+
|
|
5
|
+
/** Shape of the `$inferClient` phantom `createBunderstack` puts on the app. */
|
|
6
|
+
export type ClientCarrier = {
|
|
7
|
+
schema: Record<string, unknown>
|
|
8
|
+
access: unknown
|
|
9
|
+
buckets: string
|
|
10
|
+
// Optional: apps built before the trpc feature still match.
|
|
11
|
+
trpc?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type AnyBunderstackApp = { $inferClient?: ClientCarrier | undefined }
|
|
15
|
+
|
|
16
|
+
export type InferCarrier<TApp extends AnyBunderstackApp> = NonNullable<
|
|
17
|
+
TApp['$inferClient']
|
|
18
|
+
>
|
|
19
|
+
export type InferSchema<TApp extends AnyBunderstackApp> =
|
|
20
|
+
InferCarrier<TApp>['schema']
|
|
21
|
+
export type InferBuckets<TApp extends AnyBunderstackApp> =
|
|
22
|
+
InferCarrier<TApp>['buckets']
|
|
23
|
+
|
|
24
|
+
/** The app's tRPC router type, or `never` when it doesn't declare one. */
|
|
25
|
+
export type InferTrpcRouter<TApp extends AnyBunderstackApp> =
|
|
26
|
+
InferCarrier<TApp>['trpc'] extends infer R
|
|
27
|
+
? R extends AnyRouter
|
|
28
|
+
? R
|
|
29
|
+
: never
|
|
30
|
+
: never
|
|
31
|
+
|
|
32
|
+
type DisabledKeys<TAccess> = {
|
|
33
|
+
[K in keyof TAccess & string]: TAccess[K] extends { crud: false } ? K : never
|
|
34
|
+
}[keyof TAccess & string]
|
|
35
|
+
|
|
36
|
+
/** Tables with an explicit access entry (auth tables need exposeAuthTable). */
|
|
37
|
+
type ExplicitKeys<TSchema, TAccess> = {
|
|
38
|
+
[K in keyof TAccess & keyof TSchema & string]: TAccess[K] extends {
|
|
39
|
+
crud: false
|
|
40
|
+
}
|
|
41
|
+
? never
|
|
42
|
+
: K extends AuthTableName
|
|
43
|
+
? TAccess[K] extends { exposeAuthTable: true }
|
|
44
|
+
? K extends 'user'
|
|
45
|
+
? K
|
|
46
|
+
: never
|
|
47
|
+
: never
|
|
48
|
+
: K
|
|
49
|
+
}[keyof TAccess & keyof TSchema & string]
|
|
50
|
+
|
|
51
|
+
/** Tables with a `userId` column get convention CRUD without an access entry. */
|
|
52
|
+
type ConventionKeys<TSchema> = {
|
|
53
|
+
[K in keyof TSchema & string]: K extends AuthTableName
|
|
54
|
+
? never
|
|
55
|
+
: InferSelect<TSchema[K]> extends { userId: unknown }
|
|
56
|
+
? K
|
|
57
|
+
: never
|
|
58
|
+
}[keyof TSchema & string]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Type-level mirror of validateAndResolveAccess's exposure rules. Slightly
|
|
62
|
+
* permissive on edge cases (a wrongly-included table 404s at runtime, same
|
|
63
|
+
* as a hand-written tables tuple — never silently narrower).
|
|
64
|
+
*/
|
|
65
|
+
export type ExposedTables<TSchema extends Record<string, unknown>, TAccess> = [
|
|
66
|
+
TAccess,
|
|
67
|
+
] extends [undefined]
|
|
68
|
+
? CrudTableKey<TSchema>
|
|
69
|
+
:
|
|
70
|
+
| ExplicitKeys<TSchema, TAccess>
|
|
71
|
+
| Exclude<
|
|
72
|
+
ConventionKeys<TSchema>,
|
|
73
|
+
DisabledKeys<TAccess> | (keyof TAccess & string)
|
|
74
|
+
>
|
|
75
|
+
|
|
76
|
+
export type InferTables<TApp extends AnyBunderstackApp> = ExposedTables<
|
|
77
|
+
InferSchema<TApp>,
|
|
78
|
+
InferCarrier<TApp>['access']
|
|
79
|
+
> &
|
|
80
|
+
keyof InferSchema<TApp> &
|
|
81
|
+
string
|