bunderstack-sync 0.16.0 → 0.17.0-beta.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/README.md +27 -34
- package/package.json +4 -4
- package/src/collection.ts +130 -38
- package/src/index.ts +3 -133
- package/src/realtime-sync.ts +24 -36
- package/src/sync-client.ts +62 -92
- package/src/update-queue.ts +93 -0
package/README.md
CHANGED
|
@@ -1,48 +1,41 @@
|
|
|
1
1
|
# bunderstack-sync
|
|
2
2
|
|
|
3
|
-
TanStack DB collections
|
|
4
|
-
|
|
5
|
-
optimistic mutations with SSE-driven realtime sync.
|
|
3
|
+
Optimistic TanStack DB collections backed by Bunderstack's unified oRPC graph
|
|
4
|
+
and Publisher realtime.
|
|
6
5
|
|
|
7
6
|
```sh
|
|
8
|
-
bun add bunderstack-sync
|
|
7
|
+
bun add bunderstack-sync @tanstack/db @tanstack/react-query
|
|
9
8
|
```
|
|
10
9
|
|
|
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
10
|
```ts
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
11
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
12
|
+
import { createSyncClient } from 'bunderstack-sync'
|
|
13
|
+
import type { App } from '../server/bunderstack'
|
|
14
|
+
|
|
15
|
+
const queryClient = new QueryClient()
|
|
16
|
+
const api = createSyncClient<App>({ queryClient })
|
|
17
|
+
|
|
18
|
+
const allPosts = api.posts.collection
|
|
19
|
+
const feed = api.posts.scopedCollection({
|
|
20
|
+
filter: { replyToId: null },
|
|
21
|
+
sort: 'createdAt',
|
|
22
|
+
order: 'desc',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
await feed.loadMore()
|
|
26
|
+
api.realtime?.close()
|
|
24
27
|
```
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
29
|
-
uniquely dangerous: if one is ever present (e.g. left over from an earlier
|
|
30
|
-
`link:`/`file:` dependency on a local checkout, then never cleaned up after
|
|
31
|
-
switching to a registry version), both `tsc` and the bundler will resolve
|
|
32
|
-
peer packages like `@tanstack/db`, `@tanstack/react-query`, or `react` from
|
|
33
|
-
inside it instead of your app's own `node_modules`. Two copies of a
|
|
34
|
-
context-carrying package means two separate module instances at runtime —
|
|
35
|
-
collections, providers, or contexts from one copy become invisible to hooks
|
|
36
|
-
from the other. If you hit unexplained "not found"/mismatched-type errors,
|
|
37
|
-
check for nested `node_modules` under this package's install location before
|
|
38
|
-
assuming it's an application bug:
|
|
29
|
+
Collections map optimistic inserts, updates, and deletes directly to generated
|
|
30
|
+
oRPC procedures. Every materialized view receives access-filtered row changes
|
|
31
|
+
from `realtime.changes`; reconnects are handled by oRPC Publisher metadata.
|
|
39
32
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```
|
|
33
|
+
This package publishes TypeScript source. Node-based SSR bundlers should bundle
|
|
34
|
+
it instead of externalizing it; for Vite:
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
```ts
|
|
37
|
+
ssr: { noExternal: [/^bunderstack/] }
|
|
38
|
+
```
|
|
46
39
|
|
|
47
40
|
## License
|
|
48
41
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack-sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "TanStack DB collections
|
|
3
|
+
"version": "0.17.0-beta.0",
|
|
4
|
+
"description": "TanStack DB collections synchronized through Bunderstack's typed oRPC client and realtime iterator.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"bunderstack",
|
|
8
8
|
"optimistic",
|
|
9
9
|
"realtime",
|
|
10
|
-
"
|
|
10
|
+
"orpc",
|
|
11
11
|
"sync",
|
|
12
12
|
"tanstack-db"
|
|
13
13
|
],
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"bunderstack-query": "^0.
|
|
38
|
+
"bunderstack-query": "^0.17.0-beta.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@tanstack/db": "0.6.16",
|
package/src/collection.ts
CHANGED
|
@@ -6,16 +6,47 @@ import {
|
|
|
6
6
|
type StandardSchema,
|
|
7
7
|
} from '@tanstack/db'
|
|
8
8
|
import { queryCollectionOptions } from '@tanstack/query-db-collection'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
|
|
10
|
+
import { createUpdateQueue } from './update-queue'
|
|
11
|
+
const MAX_LIST_LIMIT = 200
|
|
12
|
+
|
|
13
|
+
type TableListResult<TRow> = {
|
|
14
|
+
items: TRow[]
|
|
15
|
+
hasMore: boolean
|
|
16
|
+
nextCursor?: string
|
|
17
|
+
total?: number
|
|
18
|
+
limit?: number
|
|
19
|
+
offset?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type TableProcedures<TRow, TCreate, TUpdate> = {
|
|
23
|
+
list: {
|
|
24
|
+
call(input?: Record<string, unknown>): Promise<TableListResult<TRow>>
|
|
25
|
+
}
|
|
26
|
+
get: { call(input: { id: string }): Promise<TRow> }
|
|
27
|
+
create: { call(input: TCreate): Promise<TRow> }
|
|
28
|
+
update: {
|
|
29
|
+
call(input: {
|
|
30
|
+
params: { id: string }
|
|
31
|
+
query: {}
|
|
32
|
+
headers: {}
|
|
33
|
+
body: TUpdate
|
|
34
|
+
}): Promise<TRow>
|
|
35
|
+
}
|
|
36
|
+
delete: { call(input: { id: string }): Promise<void> }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DirectTableApi<TRow, TCreate, TUpdate> = {
|
|
40
|
+
list(input?: Record<string, unknown>): Promise<TableListResult<TRow>>
|
|
41
|
+
get(id: string | number): Promise<TRow>
|
|
42
|
+
create(input: TCreate): Promise<TRow>
|
|
43
|
+
update(id: string | number, input: TUpdate): Promise<TRow>
|
|
44
|
+
delete(id: string | number): Promise<void>
|
|
45
|
+
}
|
|
14
46
|
|
|
15
47
|
export type TableCollectionConfig = {
|
|
16
48
|
tableName: string
|
|
17
|
-
|
|
18
|
-
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
49
|
+
procedures: TableProcedures<any, any, any>
|
|
19
50
|
queryClient: QueryClient
|
|
20
51
|
/** Rows the default `.collection` syncs per fetch. Defaults to 100. For
|
|
21
52
|
* feed-shaped tables that need real pagination use `scopedCollection`. */
|
|
@@ -71,48 +102,110 @@ export function createTableCollection<
|
|
|
71
102
|
TCreate = Partial<TRow>,
|
|
72
103
|
TUpdate = Partial<TRow>,
|
|
73
104
|
>(config: TableCollectionConfig) {
|
|
74
|
-
const table =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
const table = config.procedures as TableProcedures<TRow, TCreate, TUpdate>
|
|
106
|
+
const direct: DirectTableApi<TRow, TCreate, TUpdate> = {
|
|
107
|
+
list(input = {}) {
|
|
108
|
+
const { limit, offset, cursor, sort, order, q, count, ...filters } = input
|
|
109
|
+
return table.list.call({
|
|
110
|
+
limit,
|
|
111
|
+
offset,
|
|
112
|
+
cursor,
|
|
113
|
+
sort,
|
|
114
|
+
order,
|
|
115
|
+
q,
|
|
116
|
+
count,
|
|
117
|
+
filters,
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
get: (id) => table.get.call({ id: String(id) }),
|
|
121
|
+
create: (input) => table.create.call(input),
|
|
122
|
+
update: (id, input) =>
|
|
123
|
+
table.update.call({
|
|
124
|
+
params: { id: String(id) },
|
|
125
|
+
query: {},
|
|
126
|
+
headers: {},
|
|
127
|
+
body: input,
|
|
128
|
+
}),
|
|
129
|
+
delete: (id) => table.delete.call({ id: String(id) }),
|
|
130
|
+
}
|
|
79
131
|
|
|
80
132
|
const collection = createCollection(
|
|
81
133
|
queryCollectionOptions<TRow>({
|
|
82
134
|
queryKey: [config.tableName, 'collection'],
|
|
83
135
|
queryFn: async () => {
|
|
84
|
-
const page = await table.list({ limit: config.limit ?? 100 })
|
|
136
|
+
const page = await table.list.call({ limit: config.limit ?? 100 })
|
|
85
137
|
return page.items
|
|
86
138
|
},
|
|
87
139
|
queryClient: config.queryClient,
|
|
88
140
|
getKey: (item) => item.id,
|
|
89
141
|
onInsert: async ({ transaction }) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
142
|
+
let reconciled = true
|
|
143
|
+
for (const mutation of transaction.mutations) {
|
|
144
|
+
// Pass the client-generated `id` through as-is. TanStack DB's
|
|
145
|
+
// optimistic insert keys the local row by this `id` (via `getKey`),
|
|
146
|
+
// and this matches `sanitizeWriteBody`'s default on the server: a
|
|
147
|
+
// client-supplied `id` on create is accepted unless the table's
|
|
148
|
+
// access config sets an explicit `writableColumns` allowlist that
|
|
149
|
+
// excludes `id`.
|
|
150
|
+
const row = (await table.create.call(
|
|
151
|
+
mutation.modified as unknown as TCreate,
|
|
152
|
+
)) as TRow | undefined
|
|
153
|
+
// Apps that DO restrict it that way get a server-assigned id, which
|
|
154
|
+
// leaves the optimistic row keyed under the client's — `writeUpsert`
|
|
155
|
+
// can't retire that, so those fall back to the refetch.
|
|
156
|
+
if (row?.id != null && String(row.id) === String(mutation.key)) {
|
|
157
|
+
applyRealtimeEvent('create', row as Record<string, unknown>)
|
|
158
|
+
} else {
|
|
159
|
+
reconciled = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return reconciled ? { refetch: false } : {}
|
|
101
163
|
},
|
|
102
164
|
onUpdate: async ({ transaction }) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
await Promise.all(
|
|
166
|
+
transaction.mutations.map((mutation) =>
|
|
167
|
+
updateQueue.enqueue(
|
|
168
|
+
mutation.key as TRow['id'],
|
|
169
|
+
mutation.changes as Record<string, unknown>,
|
|
170
|
+
),
|
|
171
|
+
),
|
|
107
172
|
)
|
|
173
|
+
return { refetch: false }
|
|
108
174
|
},
|
|
109
175
|
onDelete: async ({ transaction }) => {
|
|
110
|
-
const mutation
|
|
111
|
-
|
|
176
|
+
for (const mutation of transaction.mutations) {
|
|
177
|
+
// Updates still queued for this row are superseded, and one already
|
|
178
|
+
// in flight must land before the DELETE or the two race.
|
|
179
|
+
await updateQueue.settle(mutation.key as TRow['id'])
|
|
180
|
+
await table.delete.call({ id: String(mutation.key) })
|
|
181
|
+
applyRealtimeEvent('delete', { id: mutation.key })
|
|
182
|
+
}
|
|
183
|
+
return { refetch: false }
|
|
112
184
|
},
|
|
113
185
|
}),
|
|
114
186
|
)
|
|
115
187
|
|
|
188
|
+
// Cursor-shaped workloads call `update()` many times a second. The queue
|
|
189
|
+
// merges everything that piles up for a row while its request is in flight
|
|
190
|
+
// into a single follow-up, so request rate tracks RTT, not call rate.
|
|
191
|
+
const updateQueue = createUpdateQueue<TRow['id'], TRow>({
|
|
192
|
+
send: (id, changes) =>
|
|
193
|
+
table.update.call({
|
|
194
|
+
params: { id: String(id) },
|
|
195
|
+
query: {},
|
|
196
|
+
headers: {},
|
|
197
|
+
body: changes as unknown as TUpdate,
|
|
198
|
+
}),
|
|
199
|
+
onResult: async (row) => {
|
|
200
|
+
// `writeUpsert` writes the response whole, so mutation endpoints are
|
|
201
|
+
// contracted to return a full row. A body without an id can't be
|
|
202
|
+
// reconciled locally — fall back to the refetch rather than write it.
|
|
203
|
+
if ((row as TRow | undefined)?.id == null)
|
|
204
|
+
await collection.utils.refetch()
|
|
205
|
+
else applyRealtimeEvent('update', row as Record<string, unknown>)
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
|
|
116
209
|
type Collection = typeof collection
|
|
117
210
|
|
|
118
211
|
// Scoped/byIds collections register here so realtime events fan out to
|
|
@@ -172,11 +265,10 @@ export function createTableCollection<
|
|
|
172
265
|
let more = false
|
|
173
266
|
while (items.length < desiredCount) {
|
|
174
267
|
const remaining = Math.min(pageSize, desiredCount - items.length)
|
|
175
|
-
const page = await table.list({
|
|
176
|
-
|
|
268
|
+
const page = await table.list.call({
|
|
269
|
+
filters: filter,
|
|
177
270
|
...(options.sort ? { sort: options.sort } : {}),
|
|
178
271
|
...(options.order ? { order: options.order } : {}),
|
|
179
|
-
cursorMode: true,
|
|
180
272
|
limit: remaining,
|
|
181
273
|
...(cursor ? { cursor } : {}),
|
|
182
274
|
})
|
|
@@ -235,8 +327,8 @@ export function createTableCollection<
|
|
|
235
327
|
// Chunked at the server's IN-filter cap so any id set works.
|
|
236
328
|
for (let i = 0; i < unique.length; i += MAX_LIST_LIMIT) {
|
|
237
329
|
const chunk = unique.slice(i, i + MAX_LIST_LIMIT)
|
|
238
|
-
const page = await table.list({
|
|
239
|
-
[column]: chunk,
|
|
330
|
+
const page = await table.list.call({
|
|
331
|
+
filters: { [column]: chunk },
|
|
240
332
|
limit: chunk.length,
|
|
241
333
|
})
|
|
242
334
|
items.push(...page.items)
|
|
@@ -281,7 +373,7 @@ export function createTableCollection<
|
|
|
281
373
|
for (const entry of registry) apply(entry.collection, entry.matches)
|
|
282
374
|
}
|
|
283
375
|
|
|
284
|
-
/** Refetch the base collection plus every scoped/byIds view
|
|
376
|
+
/** Refetch the base collection plus every scoped/byIds view after reconnect. */
|
|
285
377
|
async function refetchAll() {
|
|
286
378
|
await Promise.all([
|
|
287
379
|
collection.utils.refetch(),
|
|
@@ -291,7 +383,7 @@ export function createTableCollection<
|
|
|
291
383
|
|
|
292
384
|
return {
|
|
293
385
|
collection,
|
|
294
|
-
table:
|
|
386
|
+
table: direct,
|
|
295
387
|
scopedCollection,
|
|
296
388
|
collectionByIds,
|
|
297
389
|
applyRealtimeEvent,
|
|
@@ -322,7 +414,7 @@ export type TableCollection<
|
|
|
322
414
|
Record<string, any>,
|
|
323
415
|
StandardSchema<TRow>
|
|
324
416
|
>
|
|
325
|
-
table:
|
|
417
|
+
table: DirectTableApi<TRow, TCreate, TUpdate>
|
|
326
418
|
scopedCollection: (
|
|
327
419
|
options?: ScopedCollectionOptions,
|
|
328
420
|
) => ScopedCollection<TRow>
|
package/src/index.ts
CHANGED
|
@@ -1,137 +1,7 @@
|
|
|
1
|
-
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createBunderstackQueryClient,
|
|
5
|
-
type FilesQueryClient,
|
|
6
|
-
BunderstackApiError,
|
|
7
|
-
type InferSelect,
|
|
8
|
-
type InferInsert,
|
|
9
|
-
type UploadedFile,
|
|
10
|
-
} from 'bunderstack-query'
|
|
11
|
-
|
|
12
|
-
import type { CreateFor, RowFor } from './sync-client'
|
|
13
|
-
|
|
14
|
-
import { createTableCollection, type TableCollection } from './collection'
|
|
15
|
-
import { createSyncRealtimeClient } from './realtime-sync'
|
|
16
|
-
|
|
17
|
-
type BaseOptions = {
|
|
18
|
-
baseUrl?: string
|
|
19
|
-
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
20
|
-
queryClient: QueryClient
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
type SyncTablesClient<
|
|
24
|
-
TSchema extends Record<string, unknown>,
|
|
25
|
-
TTable extends keyof TSchema & string,
|
|
26
|
-
> = {
|
|
27
|
-
[K in TTable]: TableCollection<
|
|
28
|
-
RowFor<TSchema, K>,
|
|
29
|
-
CreateFor<TSchema, K>,
|
|
30
|
-
Partial<RowFor<TSchema, K>>
|
|
31
|
-
>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function createBunderstackSyncClient<
|
|
35
|
-
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
36
|
-
>() {
|
|
37
|
-
return {
|
|
38
|
-
with<
|
|
39
|
-
const TTables extends readonly (keyof TSchema & string)[],
|
|
40
|
-
const TBuckets extends readonly string[],
|
|
41
|
-
>(
|
|
42
|
-
options: BaseOptions & {
|
|
43
|
-
tables: TTables
|
|
44
|
-
buckets: TBuckets
|
|
45
|
-
/** Subscribe these tables to live SSE updates. Defaults to true in
|
|
46
|
-
* the browser, false during SSR. */
|
|
47
|
-
realtime?: boolean
|
|
48
|
-
},
|
|
49
|
-
) {
|
|
50
|
-
const baseUrl = options.baseUrl ?? '/api'
|
|
51
|
-
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
52
|
-
|
|
53
|
-
const tablesClient: Record<
|
|
54
|
-
string,
|
|
55
|
-
ReturnType<typeof createTableCollection>
|
|
56
|
-
> = {}
|
|
57
|
-
for (const tableKey of options.tables) {
|
|
58
|
-
tablesClient[tableKey] = createTableCollection({
|
|
59
|
-
tableName: tableKey,
|
|
60
|
-
baseUrl,
|
|
61
|
-
fetch: fetchFn,
|
|
62
|
-
queryClient: options.queryClient,
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const filesClient: FilesQueryClient<TBuckets[number]> =
|
|
67
|
-
createBunderstackQueryClient<TSchema>().withFiles({
|
|
68
|
-
baseUrl,
|
|
69
|
-
fetch: fetchFn,
|
|
70
|
-
buckets: options.buckets,
|
|
71
|
-
queryClient: options.queryClient,
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
// Realtime needs a browser-side persistent connection; default off in SSR.
|
|
75
|
-
const realtime = !(options.realtime ?? typeof window !== 'undefined')
|
|
76
|
-
? undefined
|
|
77
|
-
: createSyncRealtimeClient({
|
|
78
|
-
baseUrl,
|
|
79
|
-
queryClient: options.queryClient,
|
|
80
|
-
// `createSyncRealtimeClient` expects the ambient `typeof fetch`
|
|
81
|
-
// (which includes Bun's `preconnect` static), while our public
|
|
82
|
-
// options accept any plain fetch-shaped function — the two
|
|
83
|
-
// signatures are otherwise call-compatible.
|
|
84
|
-
fetch: fetchFn as typeof fetch,
|
|
85
|
-
// Individual collections are typed against their own row shape
|
|
86
|
-
// (inferred from `createTableCollection`'s constraint since
|
|
87
|
-
// this loop can't carry a per-key TRow), which is narrower
|
|
88
|
-
// than `SyncableCollection`'s `unknown`-typed utils. Safe here
|
|
89
|
-
// because `realtime-sync.ts` only ever passes server-decoded
|
|
90
|
-
// records through, matching each collection's own row shape.
|
|
91
|
-
collections: Object.fromEntries(
|
|
92
|
-
Object.entries(tablesClient).map(([k, v]) => [k, v.collection]),
|
|
93
|
-
) as unknown as NonNullable<
|
|
94
|
-
Parameters<typeof createSyncRealtimeClient>[0]['collections']
|
|
95
|
-
>,
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
...tablesClient,
|
|
100
|
-
...filesClient,
|
|
101
|
-
realtime,
|
|
102
|
-
} as unknown as SyncTablesClient<TSchema, TTables[number]> &
|
|
103
|
-
FilesQueryClient<TBuckets[number]> & {
|
|
104
|
-
realtime: typeof realtime
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
1
|
export { createSyncClient } from './sync-client'
|
|
111
|
-
export type {
|
|
112
|
-
BunderstackSyncClient,
|
|
113
|
-
CreateFor,
|
|
114
|
-
RowFor,
|
|
115
|
-
SyncClientOptions,
|
|
116
|
-
} from './sync-client'
|
|
2
|
+
export type { BunderstackSyncClient, CreateFor, RowFor, SyncClientOptions } from './sync-client'
|
|
117
3
|
export { createTableCollection } from './collection'
|
|
118
|
-
export type {
|
|
119
|
-
ScopedCollectionOptions,
|
|
120
|
-
ScopedFilterValue,
|
|
121
|
-
TableCollection,
|
|
122
|
-
TableCollectionConfig,
|
|
123
|
-
} from './collection'
|
|
4
|
+
export type { ScopedCollectionOptions, ScopedFilterValue, TableCollection, TableCollectionConfig } from './collection'
|
|
124
5
|
export { createSyncRealtimeClient } from './realtime-sync'
|
|
125
6
|
export type { SyncRealtimeConfig, SyncRealtimeTarget } from './realtime-sync'
|
|
126
|
-
|
|
127
|
-
// Re-export bunderstack-query types and utilities for convenience
|
|
128
|
-
export { BunderstackApiError, MAX_LIST_LIMIT } from 'bunderstack-query'
|
|
129
|
-
export type {
|
|
130
|
-
AnyBunderstackApp,
|
|
131
|
-
InferBuckets,
|
|
132
|
-
InferInsert,
|
|
133
|
-
InferSchema,
|
|
134
|
-
InferSelect,
|
|
135
|
-
InferTables,
|
|
136
|
-
UploadedFile,
|
|
137
|
-
} from 'bunderstack-query'
|
|
7
|
+
export type { AnyBunderstackApp, InferBuckets, InferInsert, InferSchema, InferSelect, InferTables, UploadedFile } from 'bunderstack-query'
|
package/src/realtime-sync.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
syncRealtime,
|
|
4
|
+
type RealtimeQueryApi,
|
|
5
|
+
type RealtimeSyncHandle,
|
|
6
|
+
} from 'bunderstack-query'
|
|
4
7
|
|
|
5
8
|
type SyncableCollection = {
|
|
6
9
|
utils: {
|
|
@@ -10,7 +13,6 @@ type SyncableCollection = {
|
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
/** A table bundle the resolver mode routes events into (see collection.ts). */
|
|
14
16
|
export type SyncRealtimeTarget = {
|
|
15
17
|
applyRealtimeEvent: (
|
|
16
18
|
action: 'create' | 'update' | 'delete',
|
|
@@ -20,54 +22,40 @@ export type SyncRealtimeTarget = {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export type SyncRealtimeConfig = {
|
|
23
|
-
|
|
25
|
+
api: RealtimeQueryApi
|
|
24
26
|
queryClient: QueryClient
|
|
25
|
-
|
|
26
|
-
/** Static map of table name -> the collection that table's rows sync into. */
|
|
27
|
+
tables: string[]
|
|
27
28
|
collections?: Record<string, SyncableCollection>
|
|
28
|
-
/** Lazy lookup: resolve a table's target at event time (proxy clients that
|
|
29
|
-
* can't enumerate tables upfront). Takes precedence over `collections`. */
|
|
30
29
|
resolve?: (table: string) => SyncRealtimeTarget | undefined
|
|
31
|
-
/** All materialized targets — used for gap recovery in resolver mode. */
|
|
32
30
|
resolveAll?: () => Iterable<SyncRealtimeTarget>
|
|
31
|
+
retryMs?: number
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
export function createSyncRealtimeClient(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
|
|
34
|
+
export function createSyncRealtimeClient(
|
|
35
|
+
config: SyncRealtimeConfig,
|
|
36
|
+
): RealtimeSyncHandle {
|
|
37
|
+
const collections = config.collections ?? {}
|
|
38
|
+
return syncRealtime({
|
|
39
|
+
api: config.api,
|
|
41
40
|
queryClient: config.queryClient,
|
|
42
|
-
tables,
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
tables: config.tables,
|
|
42
|
+
retryMs: config.retryMs,
|
|
43
|
+
onChange: (event) => {
|
|
45
44
|
if (config.resolve) {
|
|
46
|
-
config.resolve(
|
|
45
|
+
config.resolve(event.table)?.applyRealtimeEvent(event.action, event.record)
|
|
47
46
|
return
|
|
48
47
|
}
|
|
49
|
-
const collection =
|
|
48
|
+
const collection = collections[event.table]
|
|
50
49
|
if (!collection) return
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
} else {
|
|
54
|
-
collection.utils.writeUpsert(evt.record)
|
|
55
|
-
}
|
|
50
|
+
if (event.action === 'delete') collection.utils.writeDelete(event.record['id'])
|
|
51
|
+
else collection.utils.writeUpsert(event.record)
|
|
56
52
|
},
|
|
57
|
-
|
|
53
|
+
onReconnect: async () => {
|
|
58
54
|
if (config.resolveAll) {
|
|
59
|
-
|
|
60
|
-
target.refetchAll().catch((err) => {
|
|
61
|
-
console.error('bunderstack-sync: gap-recovery refetch failed', err)
|
|
62
|
-
})
|
|
63
|
-
}
|
|
55
|
+
await Promise.all([...config.resolveAll()].map((target) => target.refetchAll()))
|
|
64
56
|
return
|
|
65
57
|
}
|
|
66
|
-
|
|
67
|
-
collection.utils.refetch().catch((err) => {
|
|
68
|
-
console.error('bunderstack-sync: gap-recovery refetch failed', err)
|
|
69
|
-
})
|
|
70
|
-
}
|
|
58
|
+
await Promise.all(Object.values(collections).map((collection) => collection.utils.refetch()))
|
|
71
59
|
},
|
|
72
60
|
})
|
|
73
61
|
}
|
package/src/sync-client.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
-
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
createBucketClient,
|
|
6
|
-
lazyRecord,
|
|
3
|
+
createClient,
|
|
7
4
|
type AnyBunderstackApp,
|
|
8
|
-
type
|
|
5
|
+
type BunderstackClient,
|
|
9
6
|
type InferBuckets,
|
|
10
7
|
type InferInsert,
|
|
11
8
|
type InferSchema,
|
|
@@ -14,32 +11,27 @@ import {
|
|
|
14
11
|
} from 'bunderstack-query'
|
|
15
12
|
|
|
16
13
|
import { createTableCollection, type TableCollection } from './collection'
|
|
17
|
-
import {
|
|
18
|
-
createSyncRealtimeClient,
|
|
19
|
-
type SyncRealtimeTarget,
|
|
20
|
-
} from './realtime-sync'
|
|
14
|
+
import { createSyncRealtimeClient, type SyncRealtimeTarget } from './realtime-sync'
|
|
21
15
|
|
|
22
|
-
export type RowFor<
|
|
23
|
-
TSchema extends
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
? InferSelect<TSchema[K]>
|
|
29
|
-
: { id: string | number }
|
|
16
|
+
export type RowFor<TSchema extends Record<string, unknown>, K extends keyof TSchema> =
|
|
17
|
+
[InferSelect<TSchema[K]>] extends [never]
|
|
18
|
+
? { id: string | number }
|
|
19
|
+
: InferSelect<TSchema[K]> extends { id: string | number }
|
|
20
|
+
? InferSelect<TSchema[K]>
|
|
21
|
+
: { id: string | number }
|
|
30
22
|
|
|
31
|
-
export type CreateFor<
|
|
32
|
-
TSchema extends
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
? Partial<RowFor<TSchema, K>>
|
|
36
|
-
: InferInsert<TSchema[K]>
|
|
23
|
+
export type CreateFor<TSchema extends Record<string, unknown>, K extends keyof TSchema> =
|
|
24
|
+
[InferInsert<TSchema[K]>] extends [never]
|
|
25
|
+
? Partial<RowFor<TSchema, K>>
|
|
26
|
+
: InferInsert<TSchema[K]>
|
|
37
27
|
|
|
38
28
|
export type SyncClientOptions = {
|
|
39
29
|
baseUrl?: string
|
|
40
|
-
fetch?: (
|
|
30
|
+
fetch?: (
|
|
31
|
+
input: RequestInfo | URL,
|
|
32
|
+
init?: RequestInit,
|
|
33
|
+
) => Promise<Response>
|
|
41
34
|
queryClient: QueryClient
|
|
42
|
-
/** Live SSE updates. Defaults to true in the browser, false during SSR. */
|
|
43
35
|
realtime?: boolean
|
|
44
36
|
}
|
|
45
37
|
|
|
@@ -49,80 +41,58 @@ export type BunderstackSyncClient<TApp extends AnyBunderstackApp> = {
|
|
|
49
41
|
CreateFor<InferSchema<TApp>, K>,
|
|
50
42
|
Partial<RowFor<InferSchema<TApp>, K>>
|
|
51
43
|
>
|
|
52
|
-
} &
|
|
53
|
-
|
|
54
|
-
}
|
|
44
|
+
} & {
|
|
45
|
+
files: BunderstackClient<TApp>['files']
|
|
46
|
+
realtime: { close(): void; subscribe(tables: string[]): Promise<void> } | undefined
|
|
47
|
+
}
|
|
55
48
|
|
|
56
|
-
/**
|
|
57
|
-
* Fully typed sync client inferred from the server app. Tables (with their
|
|
58
|
-
* collections and scoped/byIds views) and buckets materialize lazily on
|
|
59
|
-
* first property access — no runtime table/bucket lists, and the app is
|
|
60
|
-
* referenced as a type only, so no server code lands in the bundle.
|
|
61
|
-
* Realtime events fan out to whichever collections have materialized.
|
|
62
|
-
*
|
|
63
|
-
* @example
|
|
64
|
-
* import type { App } from './bunderstack' // type-only import
|
|
65
|
-
* const api = createSyncClient<App>({ queryClient })
|
|
66
|
-
* api.posts.collection; api.posts.scopedCollection({ filter: { replyToId: null } })
|
|
67
|
-
*/
|
|
68
49
|
export function createSyncClient<TApp extends AnyBunderstackApp>(
|
|
69
50
|
options: SyncClientOptions,
|
|
70
51
|
): BunderstackSyncClient<TApp> {
|
|
71
|
-
const
|
|
72
|
-
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
73
|
-
|
|
74
|
-
// Realtime only needs the fan-out surface, so the map stays row-type-agnostic.
|
|
52
|
+
const api = createClient<TApp>(options)
|
|
75
53
|
const materialized = new Map<string, SyncRealtimeTarget>()
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
tableName,
|
|
79
|
-
baseUrl,
|
|
80
|
-
fetch: fetchFn,
|
|
81
|
-
queryClient: options.queryClient,
|
|
82
|
-
})
|
|
83
|
-
materialized.set(tableName, bundle)
|
|
84
|
-
return bundle
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
const files = lazyRecord((bucket) => {
|
|
88
|
-
const bucketClient = createBucketClient({ bucket, baseUrl, fetch: fetchFn })
|
|
89
|
-
return {
|
|
90
|
-
...bucketClient,
|
|
91
|
-
...attachBucketMutationOptions(bucketClient, options.queryClient),
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// Realtime needs a browser-side persistent connection; default off in SSR.
|
|
54
|
+
const realtimeHandles = new Map<string, { close(): void }>()
|
|
55
|
+
const tables = new Map<string, unknown>()
|
|
96
56
|
const realtimeEnabled = options.realtime ?? typeof window !== 'undefined'
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
57
|
+
|
|
58
|
+
const result = new Proxy({} as BunderstackSyncClient<TApp>, {
|
|
59
|
+
get(_target, property) {
|
|
60
|
+
if (typeof property !== 'string') return undefined
|
|
61
|
+
if (property === 'files') return api.files
|
|
62
|
+
if (property === 'realtime') {
|
|
63
|
+
return realtimeEnabled
|
|
64
|
+
? {
|
|
65
|
+
close: () => realtimeHandles.forEach((handle) => handle.close()),
|
|
66
|
+
subscribe: async (names: string[]) => {
|
|
67
|
+
for (const name of names) void (result as any)[name]
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
: undefined
|
|
71
|
+
}
|
|
72
|
+
if (['then', 'toJSON', 'constructor', '$$typeof'].includes(property)) return undefined
|
|
73
|
+
const cached = tables.get(property)
|
|
74
|
+
if (cached) return cached
|
|
75
|
+
const collection = createTableCollection({
|
|
76
|
+
tableName: property,
|
|
77
|
+
procedures: (api as any)[property],
|
|
100
78
|
queryClient: options.queryClient,
|
|
101
|
-
// Our public options accept any plain fetch-shaped function, while
|
|
102
|
-
// the realtime client expects the ambient `typeof fetch` (which
|
|
103
|
-
// includes Bun's `preconnect` static) — call-compatible otherwise.
|
|
104
|
-
fetch: fetchFn as typeof fetch,
|
|
105
|
-
resolve: (table) => materialized.get(table),
|
|
106
|
-
resolveAll: () => materialized.values(),
|
|
107
79
|
})
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return
|
|
123
|
-
},
|
|
124
|
-
has(_target, prop) {
|
|
125
|
-
return typeof prop === 'string'
|
|
80
|
+
tables.set(property, collection)
|
|
81
|
+
materialized.set(property, collection)
|
|
82
|
+
if (realtimeEnabled) {
|
|
83
|
+
realtimeHandles.set(
|
|
84
|
+
property,
|
|
85
|
+
createSyncRealtimeClient({
|
|
86
|
+
api: api as any,
|
|
87
|
+
queryClient: options.queryClient,
|
|
88
|
+
tables: [property],
|
|
89
|
+
resolve: (table) => materialized.get(table),
|
|
90
|
+
resolveAll: () => materialized.values(),
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return collection
|
|
126
95
|
},
|
|
127
|
-
})
|
|
96
|
+
})
|
|
97
|
+
return result
|
|
128
98
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Per-key coalescing queue for high-frequency row updates.
|
|
2
|
+
*
|
|
3
|
+
* Cursor-shaped workloads call `collection.update()` many times a second. Sent
|
|
4
|
+
* one-for-one, each call is a request, a database write and a broadcast. This
|
|
5
|
+
* queue holds a single merged slot per row key: while a request is in flight,
|
|
6
|
+
* further updates for that key merge into the slot and go out as one follow-up
|
|
7
|
+
* once it lands. Request frequency settles at `min(update rate, 1/RTT)` without
|
|
8
|
+
* a tuned throttle constant, and the optimistic UI is untouched — only the
|
|
9
|
+
* network is coalesced.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Waiter = {
|
|
13
|
+
resolve: () => void
|
|
14
|
+
reject: (error: unknown) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Slot = {
|
|
18
|
+
changes: Record<string, unknown>
|
|
19
|
+
waiters: Waiter[]
|
|
20
|
+
/** Promise of the running drain loop; never rejects. */
|
|
21
|
+
done: Promise<void> | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type UpdateQueueConfig<TKey, TRow> = {
|
|
25
|
+
/** Sends one merged batch of changes and resolves with the canonical row. */
|
|
26
|
+
send: (key: TKey, changes: Record<string, unknown>) => Promise<TRow>
|
|
27
|
+
/** Called with the server's row before the batch's waiters resolve. Awaited,
|
|
28
|
+
* so a handler that needs to recover (refetch) finishes first. */
|
|
29
|
+
onResult: (row: TRow) => void | Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type UpdateQueue<TKey> = {
|
|
33
|
+
/** Queues changes for `key`. Resolves once a request carrying them lands. */
|
|
34
|
+
enqueue: (key: TKey, changes: Record<string, unknown>) => Promise<void>
|
|
35
|
+
/** Drops changes still queued for `key` (resolving their waiters) and waits
|
|
36
|
+
* for any in-flight request to land. For operations that supersede pending
|
|
37
|
+
* updates and must not race them — a delete of the same row. */
|
|
38
|
+
settle: (key: TKey) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createUpdateQueue<TKey, TRow>(
|
|
42
|
+
config: UpdateQueueConfig<TKey, TRow>,
|
|
43
|
+
): UpdateQueue<TKey> {
|
|
44
|
+
const slots = new Map<TKey, Slot>()
|
|
45
|
+
|
|
46
|
+
async function drain(key: TKey, slot: Slot) {
|
|
47
|
+
while (Object.keys(slot.changes).length > 0) {
|
|
48
|
+
// Taking changes and waiters as one snapshot is what guarantees a waiter
|
|
49
|
+
// resolves only after a request that carried its own changes.
|
|
50
|
+
const changes = slot.changes
|
|
51
|
+
const waiters = slot.waiters
|
|
52
|
+
slot.changes = {}
|
|
53
|
+
slot.waiters = []
|
|
54
|
+
try {
|
|
55
|
+
await config.onResult(await config.send(key, changes))
|
|
56
|
+
for (const waiter of waiters) waiter.resolve()
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Replaying queued changes on top of a failed base would diverge from
|
|
59
|
+
// the server silently, so the whole key is rolled back instead.
|
|
60
|
+
const queued = slot.waiters
|
|
61
|
+
slot.changes = {}
|
|
62
|
+
slot.waiters = []
|
|
63
|
+
for (const waiter of [...waiters, ...queued]) waiter.reject(error)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
slots.delete(key)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
enqueue(key, changes) {
|
|
71
|
+
let slot = slots.get(key)
|
|
72
|
+
if (!slot) {
|
|
73
|
+
slot = { changes: {}, waiters: [], done: null }
|
|
74
|
+
slots.set(key, slot)
|
|
75
|
+
}
|
|
76
|
+
Object.assign(slot.changes, changes)
|
|
77
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
78
|
+
slot!.waiters.push({ resolve, reject })
|
|
79
|
+
})
|
|
80
|
+
if (!slot.done) slot.done = drain(key, slot)
|
|
81
|
+
return promise
|
|
82
|
+
},
|
|
83
|
+
settle(key) {
|
|
84
|
+
const slot = slots.get(key)
|
|
85
|
+
if (!slot) return Promise.resolve()
|
|
86
|
+
const waiters = slot.waiters
|
|
87
|
+
slot.changes = {}
|
|
88
|
+
slot.waiters = []
|
|
89
|
+
for (const waiter of waiters) waiter.resolve()
|
|
90
|
+
return slot.done ?? Promise.resolve()
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|