bunderstack-sync 0.13.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 CHANGED
@@ -1,26 +1,40 @@
1
1
  # bunderstack-sync
2
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.
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)
10
+ ```ts
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()
27
+ ```
13
28
 
14
- ## Shipping TypeScript source
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.
15
32
 
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:
33
+ This package publishes TypeScript source. Node-based SSR bundlers should bundle
34
+ it instead of externalizing it; for Vite:
19
35
 
20
36
  ```ts
21
- ssr: {
22
- noExternal: [/^bunderstack/]
23
- }
37
+ ssr: { noExternal: [/^bunderstack/] }
24
38
  ```
25
39
 
26
40
  ## License
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "bunderstack-sync",
3
- "version": "0.13.0",
4
- "description": "TanStack DB collections synced live to a bunderstack backend: optimistic mutations with SSE-driven realtime sync.",
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
- "sse",
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.13.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
@@ -1,20 +1,52 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+
1
3
  import {
2
4
  createCollection,
3
5
  type Collection,
4
6
  type StandardSchema,
5
7
  } from '@tanstack/db'
6
8
  import { queryCollectionOptions } from '@tanstack/query-db-collection'
7
- import type { QueryClient } from '@tanstack/react-query'
8
- import {
9
- createTableClient,
10
- MAX_LIST_LIMIT,
11
- type TableClient,
12
- } from 'bunderstack-query'
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
+ }
13
46
 
14
47
  export type TableCollectionConfig = {
15
48
  tableName: string
16
- baseUrl: string
17
- fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
49
+ procedures: TableProcedures<any, any, any>
18
50
  queryClient: QueryClient
19
51
  /** Rows the default `.collection` syncs per fetch. Defaults to 100. For
20
52
  * feed-shaped tables that need real pagination use `scopedCollection`. */
@@ -70,48 +102,110 @@ export function createTableCollection<
70
102
  TCreate = Partial<TRow>,
71
103
  TUpdate = Partial<TRow>,
72
104
  >(config: TableCollectionConfig) {
73
- const table = createTableClient<TRow, TCreate, TUpdate>({
74
- tableName: config.tableName,
75
- baseUrl: config.baseUrl,
76
- fetch: config.fetch,
77
- })
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
+ }
78
131
 
79
132
  const collection = createCollection(
80
133
  queryCollectionOptions<TRow>({
81
134
  queryKey: [config.tableName, 'collection'],
82
135
  queryFn: async () => {
83
- const page = await table.list({ limit: config.limit ?? 100 })
136
+ const page = await table.list.call({ limit: config.limit ?? 100 })
84
137
  return page.items
85
138
  },
86
139
  queryClient: config.queryClient,
87
140
  getKey: (item) => item.id,
88
141
  onInsert: async ({ transaction }) => {
89
- const mutation = transaction.mutations[0]!
90
- // Pass the client-generated `id` through as-is. TanStack DB's
91
- // optimistic insert keys the local row by this `id` (via `getKey`),
92
- // and this matches `sanitizeWriteBody`'s default on the server: a
93
- // client-supplied `id` on create is accepted unless the table's
94
- // access config sets an explicit `writableColumns` allowlist that
95
- // excludes `id`. Apps that DO restrict it that way will see the
96
- // server regenerate the id, and the optimistic entry's key will get
97
- // swapped once the synced row comes back — a known, narrower
98
- // trade-off in that uncommon case, not the default.
99
- await table.create(mutation.modified as unknown as Partial<TCreate>)
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 } : {}
100
163
  },
101
164
  onUpdate: async ({ transaction }) => {
102
- const mutation = transaction.mutations[0]!
103
- await table.update(
104
- mutation.key as string | number,
105
- mutation.changes as unknown as TUpdate,
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
+ ),
106
172
  )
173
+ return { refetch: false }
107
174
  },
108
175
  onDelete: async ({ transaction }) => {
109
- const mutation = transaction.mutations[0]!
110
- await table.delete(mutation.key as string | number)
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 }
111
184
  },
112
185
  }),
113
186
  )
114
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
+
115
209
  type Collection = typeof collection
116
210
 
117
211
  // Scoped/byIds collections register here so realtime events fan out to
@@ -171,11 +265,10 @@ export function createTableCollection<
171
265
  let more = false
172
266
  while (items.length < desiredCount) {
173
267
  const remaining = Math.min(pageSize, desiredCount - items.length)
174
- const page = await table.list({
175
- ...filter,
268
+ const page = await table.list.call({
269
+ filters: filter,
176
270
  ...(options.sort ? { sort: options.sort } : {}),
177
271
  ...(options.order ? { order: options.order } : {}),
178
- cursorMode: true,
179
272
  limit: remaining,
180
273
  ...(cursor ? { cursor } : {}),
181
274
  })
@@ -234,8 +327,8 @@ export function createTableCollection<
234
327
  // Chunked at the server's IN-filter cap so any id set works.
235
328
  for (let i = 0; i < unique.length; i += MAX_LIST_LIMIT) {
236
329
  const chunk = unique.slice(i, i + MAX_LIST_LIMIT)
237
- const page = await table.list({
238
- [column]: chunk,
330
+ const page = await table.list.call({
331
+ filters: { [column]: chunk },
239
332
  limit: chunk.length,
240
333
  })
241
334
  items.push(...page.items)
@@ -280,7 +373,7 @@ export function createTableCollection<
280
373
  for (const entry of registry) apply(entry.collection, entry.matches)
281
374
  }
282
375
 
283
- /** Refetch the base collection plus every scoped/byIds view (gap recovery). */
376
+ /** Refetch the base collection plus every scoped/byIds view after reconnect. */
284
377
  async function refetchAll() {
285
378
  await Promise.all([
286
379
  collection.utils.refetch(),
@@ -290,7 +383,7 @@ export function createTableCollection<
290
383
 
291
384
  return {
292
385
  collection,
293
- table: table as TableClient<TRow, TCreate, TUpdate>,
386
+ table: direct,
294
387
  scopedCollection,
295
388
  collectionByIds,
296
389
  applyRealtimeEvent,
@@ -321,7 +414,7 @@ export type TableCollection<
321
414
  Record<string, any>,
322
415
  StandardSchema<TRow>
323
416
  >
324
- table: TableClient<TRow, TCreate, TUpdate>
417
+ table: DirectTableApi<TRow, TCreate, TUpdate>
325
418
  scopedCollection: (
326
419
  options?: ScopedCollectionOptions,
327
420
  ) => ScopedCollection<TRow>
package/src/index.ts CHANGED
@@ -1,139 +1,7 @@
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
1
  export { createSyncClient } from './sync-client'
113
- export type {
114
- BunderstackSyncClient,
115
- CreateFor,
116
- RowFor,
117
- SyncClientOptions,
118
- } from './sync-client'
2
+ export type { BunderstackSyncClient, CreateFor, RowFor, SyncClientOptions } from './sync-client'
119
3
  export { createTableCollection } from './collection'
120
- export type {
121
- ScopedCollectionOptions,
122
- ScopedFilterValue,
123
- TableCollection,
124
- TableCollectionConfig,
125
- } from './collection'
4
+ export type { ScopedCollectionOptions, ScopedFilterValue, TableCollection, TableCollectionConfig } from './collection'
126
5
  export { createSyncRealtimeClient } from './realtime-sync'
127
6
  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'
7
+ export type { AnyBunderstackApp, InferBuckets, InferInsert, InferSchema, InferSelect, InferTables, UploadedFile } from 'bunderstack-query'
@@ -1,5 +1,9 @@
1
- import { createRealtimeClient, type RealtimeEvent } from 'bunderstack-query'
2
1
  import type { QueryClient } from '@tanstack/react-query'
2
+ import {
3
+ syncRealtime,
4
+ type RealtimeQueryApi,
5
+ type RealtimeSyncHandle,
6
+ } from 'bunderstack-query'
3
7
 
4
8
  type SyncableCollection = {
5
9
  utils: {
@@ -9,7 +13,6 @@ type SyncableCollection = {
9
13
  }
10
14
  }
11
15
 
12
- /** A table bundle the resolver mode routes events into (see collection.ts). */
13
16
  export type SyncRealtimeTarget = {
14
17
  applyRealtimeEvent: (
15
18
  action: 'create' | 'update' | 'delete',
@@ -19,54 +22,40 @@ export type SyncRealtimeTarget = {
19
22
  }
20
23
 
21
24
  export type SyncRealtimeConfig = {
22
- baseUrl: string
25
+ api: RealtimeQueryApi
23
26
  queryClient: QueryClient
24
- fetch?: typeof fetch
25
- /** Static map of table name -> the collection that table's rows sync into. */
27
+ tables: string[]
26
28
  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
29
  resolve?: (table: string) => SyncRealtimeTarget | undefined
30
- /** All materialized targets — used for gap recovery in resolver mode. */
31
30
  resolveAll?: () => Iterable<SyncRealtimeTarget>
31
+ retryMs?: number
32
32
  }
33
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,
34
+ export function createSyncRealtimeClient(
35
+ config: SyncRealtimeConfig,
36
+ ): RealtimeSyncHandle {
37
+ const collections = config.collections ?? {}
38
+ return syncRealtime({
39
+ api: config.api,
40
40
  queryClient: config.queryClient,
41
- tables,
42
- fetch: config.fetch,
43
- applyEvent: (evt: RealtimeEvent) => {
41
+ tables: config.tables,
42
+ retryMs: config.retryMs,
43
+ onChange: (event) => {
44
44
  if (config.resolve) {
45
- config.resolve(evt.table)?.applyRealtimeEvent(evt.action, evt.record)
45
+ config.resolve(event.table)?.applyRealtimeEvent(event.action, event.record)
46
46
  return
47
47
  }
48
- const collection = staticCollections[evt.table]
48
+ const collection = collections[event.table]
49
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
- }
50
+ if (event.action === 'delete') collection.utils.writeDelete(event.record['id'])
51
+ else collection.utils.writeUpsert(event.record)
55
52
  },
56
- onGap: () => {
53
+ onReconnect: async () => {
57
54
  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
- }
55
+ await Promise.all([...config.resolveAll()].map((target) => target.refetchAll()))
63
56
  return
64
57
  }
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
- }
58
+ await Promise.all(Object.values(collections).map((collection) => collection.utils.refetch()))
70
59
  },
71
60
  })
72
61
  }
@@ -1,10 +1,8 @@
1
1
  import type { QueryClient } from '@tanstack/react-query'
2
2
  import {
3
- attachBucketMutationOptions,
4
- createBucketClient,
5
- lazyRecord,
3
+ createClient,
6
4
  type AnyBunderstackApp,
7
- type FilesQueryClient,
5
+ type BunderstackClient,
8
6
  type InferBuckets,
9
7
  type InferInsert,
10
8
  type InferSchema,
@@ -13,32 +11,27 @@ import {
13
11
  } from 'bunderstack-query'
14
12
 
15
13
  import { createTableCollection, type TableCollection } from './collection'
16
- import {
17
- createSyncRealtimeClient,
18
- type SyncRealtimeTarget,
19
- } from './realtime-sync'
14
+ import { createSyncRealtimeClient, type SyncRealtimeTarget } from './realtime-sync'
20
15
 
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 }
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 }
29
22
 
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]>
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]>
36
27
 
37
28
  export type SyncClientOptions = {
38
29
  baseUrl?: string
39
- fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
30
+ fetch?: (
31
+ input: RequestInfo | URL,
32
+ init?: RequestInit,
33
+ ) => Promise<Response>
40
34
  queryClient: QueryClient
41
- /** Live SSE updates. Defaults to true in the browser, false during SSR. */
42
35
  realtime?: boolean
43
36
  }
44
37
 
@@ -48,80 +41,58 @@ export type BunderstackSyncClient<TApp extends AnyBunderstackApp> = {
48
41
  CreateFor<InferSchema<TApp>, K>,
49
42
  Partial<RowFor<InferSchema<TApp>, K>>
50
43
  >
51
- } & FilesQueryClient<InferBuckets<TApp>> & {
52
- realtime: ReturnType<typeof createSyncRealtimeClient> | undefined
53
- }
44
+ } & {
45
+ files: BunderstackClient<TApp>['files']
46
+ realtime: { close(): void; subscribe(tables: string[]): Promise<void> } | undefined
47
+ }
54
48
 
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
49
  export function createSyncClient<TApp extends AnyBunderstackApp>(
68
50
  options: SyncClientOptions,
69
51
  ): 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.
52
+ const api = createClient<TApp>(options)
74
53
  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.
54
+ const realtimeHandles = new Map<string, { close(): void }>()
55
+ const tables = new Map<string, unknown>()
95
56
  const realtimeEnabled = options.realtime ?? typeof window !== 'undefined'
96
- const realtime = realtimeEnabled
97
- ? createSyncRealtimeClient({
98
- baseUrl,
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],
99
78
  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
79
  })
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]
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
122
95
  },
123
- has(_target, prop) {
124
- return typeof prop === 'string'
125
- },
126
- }) as BunderstackSyncClient<TApp>
96
+ })
97
+ return result
127
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
+ }