bunderstack-sync 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,28 @@
1
+ # bunderstack-sync
2
+
3
+ TanStack DB collections synced live to a
4
+ [bunderstack](https://github.com/kirill-dev-pro/bunderstack) backend:
5
+ optimistic mutations with SSE-driven realtime sync.
6
+
7
+ ```sh
8
+ bun add bunderstack-sync
9
+ ```
10
+
11
+ Full documentation and examples:
12
+ [github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
13
+
14
+ ## Shipping TypeScript source
15
+
16
+ This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
17
+ consumes it natively. If a Node-based bundler or SSR server processes it,
18
+ make sure the package is bundled rather than externalized — e.g. in Vite:
19
+
20
+ ```ts
21
+ ssr: {
22
+ noExternal: [/^bunderstack/]
23
+ }
24
+ ```
25
+
26
+ ## License
27
+
28
+ MIT
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "bunderstack-sync",
3
+ "version": "0.1.0",
4
+ "description": "TanStack DB collections synced live to a bunderstack backend: optimistic mutations with SSE-driven realtime sync.",
5
+ "keywords": [
6
+ "bun",
7
+ "bunderstack",
8
+ "optimistic",
9
+ "realtime",
10
+ "sse",
11
+ "sync",
12
+ "tanstack-db"
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-sync"
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
+ },
34
+ "scripts": {
35
+ "test": "bun test"
36
+ },
37
+ "dependencies": {
38
+ "bunderstack": "0.1.0",
39
+ "bunderstack-query": "0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@tanstack/db": "^0.6.13",
43
+ "@tanstack/query-core": "^5.101.1",
44
+ "@tanstack/query-db-collection": "^1.0.45",
45
+ "@tanstack/react-query": "^5.101.1",
46
+ "@types/bun": "^1.3.14",
47
+ "typescript": "^5.0.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@tanstack/db": "^0.6.0",
51
+ "@tanstack/query-db-collection": "^1.0.0",
52
+ "@tanstack/react-query": "^5.101.0",
53
+ "typescript": "^5"
54
+ }
55
+ }
@@ -0,0 +1,301 @@
1
+ import { createCollection } from '@tanstack/db'
2
+ import { queryCollectionOptions } from '@tanstack/query-db-collection'
3
+ import type { QueryClient } from '@tanstack/react-query'
4
+ import {
5
+ createTableClient,
6
+ MAX_LIST_LIMIT,
7
+ type TableClient,
8
+ } from 'bunderstack-query'
9
+
10
+ export type TableCollectionConfig = {
11
+ tableName: string
12
+ baseUrl: string
13
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
14
+ queryClient: QueryClient
15
+ /** Rows the default `.collection` syncs per fetch. Defaults to 100. For
16
+ * feed-shaped tables that need real pagination use `scopedCollection`. */
17
+ limit?: number
18
+ }
19
+
20
+ export type ScopedFilterValue =
21
+ | string
22
+ | number
23
+ | boolean
24
+ | null
25
+ | readonly (string | number)[]
26
+
27
+ export type ScopedCollectionOptions = {
28
+ /** Equality filters, e.g. `{ replyToId: null }` — columns must be in the
29
+ * table's `filterableColumns` server-side. */
30
+ filter?: Record<string, ScopedFilterValue>
31
+ sort?: string
32
+ order?: 'asc' | 'desc'
33
+ /** Rows per underlying request; clamped to the server cap (200). */
34
+ pageSize?: number
35
+ /** Window size on first load and default `loadMore` step. Defaults to 20. */
36
+ initialCount?: number
37
+ }
38
+
39
+ function matchesFilter(
40
+ record: Record<string, unknown>,
41
+ filter: Record<string, ScopedFilterValue>,
42
+ ): boolean {
43
+ for (const [col, expected] of Object.entries(filter)) {
44
+ const actual = record[col]
45
+ if (expected === null) {
46
+ if (actual != null) return false
47
+ } else if (Array.isArray(expected)) {
48
+ if (!expected.map(String).includes(String(actual))) return false
49
+ } else if (String(actual) !== String(expected)) return false
50
+ }
51
+ return true
52
+ }
53
+
54
+ /** Deterministic serialization for cache keys (object key order ignored). */
55
+ function stableKey(value: unknown): string {
56
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
57
+ if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`
58
+ const entries = Object.entries(value as Record<string, unknown>)
59
+ .sort(([a], [b]) => (a < b ? -1 : 1))
60
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableKey(v)}`)
61
+ return `{${entries.join(',')}}`
62
+ }
63
+
64
+ export function createTableCollection<
65
+ TRow extends { id: string | number },
66
+ TCreate = Partial<TRow>,
67
+ TUpdate = Partial<TRow>,
68
+ >(config: TableCollectionConfig) {
69
+ const table = createTableClient<TRow, TCreate, TUpdate>({
70
+ tableName: config.tableName,
71
+ baseUrl: config.baseUrl,
72
+ fetch: config.fetch,
73
+ })
74
+
75
+ const collection = createCollection(
76
+ queryCollectionOptions<TRow>({
77
+ queryKey: [config.tableName, 'collection'],
78
+ queryFn: async () => {
79
+ const page = await table.list({ limit: config.limit ?? 100 })
80
+ return page.items
81
+ },
82
+ queryClient: config.queryClient,
83
+ getKey: (item) => item.id,
84
+ onInsert: async ({ transaction }) => {
85
+ const mutation = transaction.mutations[0]!
86
+ // Pass the client-generated `id` through as-is. TanStack DB's
87
+ // optimistic insert keys the local row by this `id` (via `getKey`),
88
+ // and this matches `sanitizeWriteBody`'s default on the server: a
89
+ // client-supplied `id` on create is accepted unless the table's
90
+ // access config sets an explicit `writableColumns` allowlist that
91
+ // excludes `id`. Apps that DO restrict it that way will see the
92
+ // server regenerate the id, and the optimistic entry's key will get
93
+ // swapped once the synced row comes back — a known, narrower
94
+ // trade-off in that uncommon case, not the default.
95
+ await table.create(mutation.modified as unknown as Partial<TCreate>)
96
+ },
97
+ onUpdate: async ({ transaction }) => {
98
+ const mutation = transaction.mutations[0]!
99
+ await table.update(
100
+ mutation.key as string | number,
101
+ mutation.changes as unknown as TUpdate,
102
+ )
103
+ },
104
+ onDelete: async ({ transaction }) => {
105
+ const mutation = transaction.mutations[0]!
106
+ await table.delete(mutation.key as string | number)
107
+ },
108
+ }),
109
+ )
110
+
111
+ type Collection = typeof collection
112
+
113
+ // Scoped/byIds collections register here so realtime events fan out to
114
+ // every live view of this table, filtered client-side by each view's
115
+ // own predicate (broadcasts are already access-filtered server-side).
116
+ const registry: {
117
+ collection: Collection
118
+ matches: (record: Record<string, unknown>) => boolean
119
+ refetch: () => Promise<void>
120
+ }[] = []
121
+
122
+ const scopedCache = new Map<string, ScopedCollection>()
123
+
124
+ type ScopedCollection = {
125
+ collection: Collection
126
+ /** Grow the window by `count` (default initialCount) and refetch in place. */
127
+ loadMore: (count?: number) => Promise<void>
128
+ /** Whether the server reported rows beyond the window (as of last fetch). */
129
+ hasMore: () => boolean
130
+ /** Current desired window size. */
131
+ size: () => number
132
+ }
133
+
134
+ function scopedCollection(
135
+ options: ScopedCollectionOptions = {},
136
+ ): ScopedCollection {
137
+ const pageSize = Math.min(
138
+ options.pageSize ?? MAX_LIST_LIMIT,
139
+ MAX_LIST_LIMIT,
140
+ )
141
+ const initialCount = options.initialCount ?? 20
142
+ const filter = options.filter ?? {}
143
+ const cacheKey = stableKey({
144
+ filter,
145
+ sort: options.sort ?? null,
146
+ order: options.order ?? null,
147
+ pageSize,
148
+ initialCount,
149
+ })
150
+ const cached = scopedCache.get(cacheKey)
151
+ if (cached) return cached
152
+
153
+ let desiredCount = initialCount
154
+ let serverHasMore = false
155
+
156
+ const scoped = createCollection(
157
+ queryCollectionOptions<TRow>({
158
+ queryKey: [config.tableName, 'scoped', cacheKey],
159
+ queryFn: async () => {
160
+ // Growing window: walk cursor pages (each ≤ the server cap) until
161
+ // the current desired count is collected or the table runs out.
162
+ // The collection stays stable across loadMore — refetching in
163
+ // place only ever adds rows, so already-rendered items never
164
+ // unmount (no scroll jumps or zero-item flashes).
165
+ const items: TRow[] = []
166
+ let cursor: string | undefined
167
+ let more = false
168
+ while (items.length < desiredCount) {
169
+ const remaining = Math.min(pageSize, desiredCount - items.length)
170
+ const page = await table.list({
171
+ ...filter,
172
+ ...(options.sort ? { sort: options.sort } : {}),
173
+ ...(options.order ? { order: options.order } : {}),
174
+ cursorMode: true,
175
+ limit: remaining,
176
+ ...(cursor ? { cursor } : {}),
177
+ })
178
+ items.push(...page.items)
179
+ more = Boolean(page.hasMore && page.nextCursor)
180
+ if (!more) break
181
+ cursor = page.nextCursor
182
+ }
183
+ serverHasMore = more
184
+ return items.slice(0, desiredCount)
185
+ },
186
+ queryClient: config.queryClient,
187
+ getKey: (item) => item.id,
188
+ }),
189
+ )
190
+
191
+ const entry: ScopedCollection = {
192
+ collection: scoped,
193
+ loadMore: async (count) => {
194
+ desiredCount += count ?? initialCount
195
+ await scoped.utils.refetch()
196
+ },
197
+ hasMore: () => serverHasMore,
198
+ size: () => desiredCount,
199
+ }
200
+ registry.push({
201
+ collection: scoped,
202
+ matches: (record) => matchesFilter(record, filter),
203
+ refetch: async () => {
204
+ await scoped.utils.refetch()
205
+ },
206
+ })
207
+ scopedCache.set(cacheKey, entry)
208
+ return entry
209
+ }
210
+
211
+ const byIdsCache = new Map<string, Collection>()
212
+
213
+ function collectionByIds(
214
+ ids: readonly TRow['id'][],
215
+ options: { column?: string } = {},
216
+ ): Collection {
217
+ const column = options.column ?? 'id'
218
+ const unique = Array.from(new Set(ids.map(String))).sort()
219
+ const cacheKey = `${column}:${unique.join(',')}`
220
+ const cached = byIdsCache.get(cacheKey)
221
+ if (cached) return cached
222
+
223
+ const idSet = new Set(unique)
224
+ const byIds = createCollection(
225
+ queryCollectionOptions<TRow>({
226
+ queryKey: [config.tableName, 'byIds', column, unique],
227
+ queryFn: async () => {
228
+ if (unique.length === 0) return []
229
+ const items: TRow[] = []
230
+ // Chunked at the server's IN-filter cap so any id set works.
231
+ for (let i = 0; i < unique.length; i += MAX_LIST_LIMIT) {
232
+ const chunk = unique.slice(i, i + MAX_LIST_LIMIT)
233
+ const page = await table.list({
234
+ [column]: chunk,
235
+ limit: chunk.length,
236
+ })
237
+ items.push(...page.items)
238
+ }
239
+ return items
240
+ },
241
+ queryClient: config.queryClient,
242
+ getKey: (item) => item.id,
243
+ }),
244
+ )
245
+ registry.push({
246
+ collection: byIds,
247
+ matches: (record) => idSet.has(String(record[column])),
248
+ refetch: async () => {
249
+ await byIds.utils.refetch()
250
+ },
251
+ })
252
+ byIdsCache.set(cacheKey, byIds)
253
+ return byIds
254
+ }
255
+
256
+ function applyRealtimeEvent(
257
+ action: 'create' | 'update' | 'delete',
258
+ record: Record<string, unknown>,
259
+ ) {
260
+ const id = record['id'] as string | number
261
+ const apply = (
262
+ target: Collection,
263
+ matches: (record: Record<string, unknown>) => boolean,
264
+ ) => {
265
+ // Collections that never started syncing (no subscribers yet) can't
266
+ // accept manual writes — and don't need to: their first sync fetches
267
+ // fresh data anyway.
268
+ if (target.status !== 'ready') return
269
+ if (action === 'delete' || !matches(record)) {
270
+ if (target.get(id) !== undefined) target.utils.writeDelete(id)
271
+ } else {
272
+ target.utils.writeUpsert(record as unknown as TRow)
273
+ }
274
+ }
275
+ apply(collection, () => true)
276
+ for (const entry of registry) apply(entry.collection, entry.matches)
277
+ }
278
+
279
+ /** Refetch the base collection plus every scoped/byIds view (gap recovery). */
280
+ async function refetchAll() {
281
+ await Promise.all([
282
+ collection.utils.refetch(),
283
+ ...registry.map((entry) => entry.refetch()),
284
+ ])
285
+ }
286
+
287
+ return {
288
+ collection,
289
+ table: table as TableClient<TRow, TCreate, TUpdate>,
290
+ scopedCollection,
291
+ collectionByIds,
292
+ applyRealtimeEvent,
293
+ refetchAll,
294
+ }
295
+ }
296
+
297
+ export type TableCollection<
298
+ TRow extends { id: string | number },
299
+ TCreate = Partial<TRow>,
300
+ TUpdate = Partial<TRow>,
301
+ > = ReturnType<typeof createTableCollection<TRow, TCreate, TUpdate>>
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import {
3
+ createBunderstackQueryClient,
4
+ type FilesQueryClient,
5
+ BunderstackApiError,
6
+ type InferSelect,
7
+ type InferInsert,
8
+ type UploadedFile,
9
+ } from 'bunderstack-query'
10
+
11
+ import { createTableCollection, type TableCollection } from './collection'
12
+ import { createSyncRealtimeClient } from './realtime-sync'
13
+ import type { CreateFor, RowFor } from './sync-client'
14
+
15
+ type BaseOptions = {
16
+ baseUrl?: string
17
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
18
+ queryClient: QueryClient
19
+ }
20
+
21
+ type SyncTablesClient<
22
+ TSchema extends Record<string, unknown>,
23
+ TTable extends keyof TSchema & string,
24
+ > = {
25
+ [K in TTable]: TableCollection<
26
+ RowFor<TSchema, K>,
27
+ CreateFor<TSchema, K>,
28
+ Partial<RowFor<TSchema, K>>
29
+ >
30
+ }
31
+
32
+ export function createBunderstackSyncClient<
33
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
34
+ >() {
35
+ return {
36
+ with<
37
+ const TTables extends readonly (keyof TSchema & string)[],
38
+ const TBuckets extends readonly string[],
39
+ >(
40
+ options: BaseOptions & {
41
+ tables: TTables
42
+ buckets: TBuckets
43
+ /** Subscribe these tables to live SSE updates. Defaults to true in
44
+ * the browser, false during SSR. */
45
+ realtime?: boolean
46
+ },
47
+ ) {
48
+ const baseUrl = options.baseUrl ?? '/api'
49
+ const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
50
+
51
+ const tablesClient: Record<
52
+ string,
53
+ ReturnType<typeof createTableCollection>
54
+ > = {}
55
+ for (const tableKey of options.tables) {
56
+ tablesClient[tableKey] = createTableCollection({
57
+ tableName: tableKey,
58
+ baseUrl,
59
+ fetch: fetchFn,
60
+ queryClient: options.queryClient,
61
+ })
62
+ }
63
+
64
+ const filesClient: FilesQueryClient<TBuckets[number]> =
65
+ createBunderstackQueryClient<TSchema>().withFiles({
66
+ baseUrl,
67
+ fetch: fetchFn,
68
+ buckets: options.buckets,
69
+ queryClient: options.queryClient,
70
+ })
71
+
72
+ // Realtime needs a browser-side persistent connection; default off in SSR.
73
+ const realtime =
74
+ !(options.realtime ?? typeof window !== 'undefined')
75
+ ? undefined
76
+ : createSyncRealtimeClient({
77
+ baseUrl,
78
+ queryClient: options.queryClient,
79
+ // `createSyncRealtimeClient` expects the ambient `typeof fetch`
80
+ // (which includes Bun's `preconnect` static), while our public
81
+ // options accept any plain fetch-shaped function — the two
82
+ // signatures are otherwise call-compatible.
83
+ fetch: fetchFn as typeof fetch,
84
+ // Individual collections are typed against their own row shape
85
+ // (inferred from `createTableCollection`'s constraint since
86
+ // this loop can't carry a per-key TRow), which is narrower
87
+ // than `SyncableCollection`'s `unknown`-typed utils. Safe here
88
+ // because `realtime-sync.ts` only ever passes server-decoded
89
+ // records through, matching each collection's own row shape.
90
+ collections: Object.fromEntries(
91
+ Object.entries(tablesClient).map(([k, v]) => [
92
+ k,
93
+ v.collection,
94
+ ]),
95
+ ) as unknown as NonNullable<
96
+ Parameters<typeof createSyncRealtimeClient>[0]['collections']
97
+ >,
98
+ })
99
+
100
+ return {
101
+ ...tablesClient,
102
+ ...filesClient,
103
+ realtime,
104
+ } as unknown as SyncTablesClient<TSchema, TTables[number]> &
105
+ FilesQueryClient<TBuckets[number]> & {
106
+ realtime: typeof realtime
107
+ }
108
+ },
109
+ }
110
+ }
111
+
112
+ export { createSyncClient } from './sync-client'
113
+ export type {
114
+ BunderstackSyncClient,
115
+ CreateFor,
116
+ RowFor,
117
+ SyncClientOptions,
118
+ } from './sync-client'
119
+ export { createTableCollection } from './collection'
120
+ export type {
121
+ ScopedCollectionOptions,
122
+ ScopedFilterValue,
123
+ TableCollection,
124
+ TableCollectionConfig,
125
+ } from './collection'
126
+ export { createSyncRealtimeClient } from './realtime-sync'
127
+ export type { SyncRealtimeConfig, SyncRealtimeTarget } from './realtime-sync'
128
+
129
+ // Re-export bunderstack-query types and utilities for convenience
130
+ export { BunderstackApiError, MAX_LIST_LIMIT } from 'bunderstack-query'
131
+ export type {
132
+ AnyBunderstackApp,
133
+ InferBuckets,
134
+ InferInsert,
135
+ InferSchema,
136
+ InferSelect,
137
+ InferTables,
138
+ UploadedFile,
139
+ } from 'bunderstack-query'
@@ -0,0 +1,72 @@
1
+ import { createRealtimeClient, type RealtimeEvent } from 'bunderstack-query'
2
+ import type { QueryClient } from '@tanstack/react-query'
3
+
4
+ type SyncableCollection = {
5
+ utils: {
6
+ writeUpsert: (item: unknown) => void
7
+ writeDelete: (key: unknown) => void
8
+ refetch: () => Promise<void>
9
+ }
10
+ }
11
+
12
+ /** A table bundle the resolver mode routes events into (see collection.ts). */
13
+ export type SyncRealtimeTarget = {
14
+ applyRealtimeEvent: (
15
+ action: 'create' | 'update' | 'delete',
16
+ record: Record<string, unknown>,
17
+ ) => void
18
+ refetchAll: () => Promise<void>
19
+ }
20
+
21
+ export type SyncRealtimeConfig = {
22
+ baseUrl: string
23
+ queryClient: QueryClient
24
+ fetch?: typeof fetch
25
+ /** Static map of table name -> the collection that table's rows sync into. */
26
+ collections?: Record<string, SyncableCollection>
27
+ /** Lazy lookup: resolve a table's target at event time (proxy clients that
28
+ * can't enumerate tables upfront). Takes precedence over `collections`. */
29
+ resolve?: (table: string) => SyncRealtimeTarget | undefined
30
+ /** All materialized targets — used for gap recovery in resolver mode. */
31
+ resolveAll?: () => Iterable<SyncRealtimeTarget>
32
+ }
33
+
34
+ export function createSyncRealtimeClient(config: SyncRealtimeConfig) {
35
+ const staticCollections = config.collections ?? {}
36
+ const tables = Object.keys(staticCollections)
37
+
38
+ return createRealtimeClient({
39
+ baseUrl: config.baseUrl,
40
+ queryClient: config.queryClient,
41
+ tables,
42
+ fetch: config.fetch,
43
+ applyEvent: (evt: RealtimeEvent) => {
44
+ if (config.resolve) {
45
+ config.resolve(evt.table)?.applyRealtimeEvent(evt.action, evt.record)
46
+ return
47
+ }
48
+ const collection = staticCollections[evt.table]
49
+ if (!collection) return
50
+ if (evt.action === 'delete') {
51
+ collection.utils.writeDelete(evt.record['id'])
52
+ } else {
53
+ collection.utils.writeUpsert(evt.record)
54
+ }
55
+ },
56
+ onGap: () => {
57
+ if (config.resolveAll) {
58
+ for (const target of config.resolveAll()) {
59
+ target.refetchAll().catch((err) => {
60
+ console.error('bunderstack-sync: gap-recovery refetch failed', err)
61
+ })
62
+ }
63
+ return
64
+ }
65
+ for (const collection of Object.values(staticCollections)) {
66
+ collection.utils.refetch().catch((err) => {
67
+ console.error('bunderstack-sync: gap-recovery refetch failed', err)
68
+ })
69
+ }
70
+ },
71
+ })
72
+ }
@@ -0,0 +1,127 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import {
3
+ attachBucketMutationOptions,
4
+ createBucketClient,
5
+ lazyRecord,
6
+ type AnyBunderstackApp,
7
+ type FilesQueryClient,
8
+ type InferBuckets,
9
+ type InferInsert,
10
+ type InferSchema,
11
+ type InferSelect,
12
+ type InferTables,
13
+ } from 'bunderstack-query'
14
+
15
+ import { createTableCollection, type TableCollection } from './collection'
16
+ import {
17
+ createSyncRealtimeClient,
18
+ type SyncRealtimeTarget,
19
+ } from './realtime-sync'
20
+
21
+ export type RowFor<
22
+ TSchema extends Record<string, unknown>,
23
+ K extends keyof TSchema,
24
+ > = [InferSelect<TSchema[K]>] extends [never]
25
+ ? { id: string | number }
26
+ : InferSelect<TSchema[K]> extends { id: string | number }
27
+ ? InferSelect<TSchema[K]>
28
+ : { id: string | number }
29
+
30
+ export type CreateFor<
31
+ TSchema extends Record<string, unknown>,
32
+ K extends keyof TSchema,
33
+ > = [InferInsert<TSchema[K]>] extends [never]
34
+ ? Partial<RowFor<TSchema, K>>
35
+ : InferInsert<TSchema[K]>
36
+
37
+ export type SyncClientOptions = {
38
+ baseUrl?: string
39
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
40
+ queryClient: QueryClient
41
+ /** Live SSE updates. Defaults to true in the browser, false during SSR. */
42
+ realtime?: boolean
43
+ }
44
+
45
+ export type BunderstackSyncClient<TApp extends AnyBunderstackApp> = {
46
+ [K in InferTables<TApp>]: TableCollection<
47
+ RowFor<InferSchema<TApp>, K>,
48
+ CreateFor<InferSchema<TApp>, K>,
49
+ Partial<RowFor<InferSchema<TApp>, K>>
50
+ >
51
+ } & FilesQueryClient<InferBuckets<TApp>> & {
52
+ realtime: ReturnType<typeof createSyncRealtimeClient> | undefined
53
+ }
54
+
55
+ /**
56
+ * Fully typed sync client inferred from the server app. Tables (with their
57
+ * collections and scoped/byIds views) and buckets materialize lazily on
58
+ * first property access — no runtime table/bucket lists, and the app is
59
+ * referenced as a type only, so no server code lands in the bundle.
60
+ * Realtime events fan out to whichever collections have materialized.
61
+ *
62
+ * @example
63
+ * import type { App } from './bunderstack' // type-only import
64
+ * const api = createSyncClient<App>({ queryClient })
65
+ * api.posts.collection; api.posts.scopedCollection({ filter: { replyToId: null } })
66
+ */
67
+ export function createSyncClient<TApp extends AnyBunderstackApp>(
68
+ options: SyncClientOptions,
69
+ ): BunderstackSyncClient<TApp> {
70
+ const baseUrl = options.baseUrl ?? '/api'
71
+ const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
72
+
73
+ // Realtime only needs the fan-out surface, so the map stays row-type-agnostic.
74
+ const materialized = new Map<string, SyncRealtimeTarget>()
75
+ const tables = lazyRecord((tableName) => {
76
+ const bundle = createTableCollection({
77
+ tableName,
78
+ baseUrl,
79
+ fetch: fetchFn,
80
+ queryClient: options.queryClient,
81
+ })
82
+ materialized.set(tableName, bundle)
83
+ return bundle
84
+ })
85
+
86
+ const files = lazyRecord((bucket) => {
87
+ const bucketClient = createBucketClient({ bucket, baseUrl, fetch: fetchFn })
88
+ return {
89
+ ...bucketClient,
90
+ ...attachBucketMutationOptions(bucketClient, options.queryClient),
91
+ }
92
+ })
93
+
94
+ // Realtime needs a browser-side persistent connection; default off in SSR.
95
+ const realtimeEnabled = options.realtime ?? typeof window !== 'undefined'
96
+ const realtime = realtimeEnabled
97
+ ? createSyncRealtimeClient({
98
+ baseUrl,
99
+ queryClient: options.queryClient,
100
+ // Our public options accept any plain fetch-shaped function, while
101
+ // the realtime client expects the ambient `typeof fetch` (which
102
+ // includes Bun's `preconnect` static) — call-compatible otherwise.
103
+ fetch: fetchFn as typeof fetch,
104
+ resolve: (table) => materialized.get(table),
105
+ resolveAll: () => materialized.values(),
106
+ })
107
+ : undefined
108
+
109
+ return new Proxy({} as BunderstackSyncClient<TApp>, {
110
+ get(_target, prop) {
111
+ if (typeof prop !== 'string') return undefined
112
+ if (prop === 'files') return files
113
+ if (prop === 'realtime') return realtime
114
+ if (
115
+ prop === 'then' ||
116
+ prop === 'toJSON' ||
117
+ prop === 'constructor' ||
118
+ prop === '$$typeof'
119
+ )
120
+ return undefined
121
+ return (tables as Record<string, unknown>)[prop]
122
+ },
123
+ has(_target, prop) {
124
+ return typeof prop === 'string'
125
+ },
126
+ }) as BunderstackSyncClient<TApp>
127
+ }