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 +21 -0
- package/README.md +28 -0
- package/package.json +55 -0
- package/src/collection.ts +301 -0
- package/src/index.ts +139 -0
- package/src/realtime-sync.ts +72 -0
- package/src/sync-client.ts +127 -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,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
|
+
}
|