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