bunderstack-sync 0.16.0 → 0.17.0-beta.2

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,48 +1,41 @@
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)
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
- ssr: {
22
- noExternal: [/^bunderstack/]
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
+ filters: { replyToId: null },
21
+ sort: 'createdAt',
22
+ order: 'desc',
23
+ })
24
+
25
+ await feed.loadMore()
26
+ api.realtime?.close()
24
27
  ```
25
28
 
26
- Because `exports` point straight at source, your TypeScript and bundler
27
- resolve modules *inside this package's own directory* rather than a compiled
28
- `dist`. That makes a stray `node_modules/bunderstack-sync/node_modules/`
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
- ```sh
41
- find node_modules -path '*/bunderstack-sync/node_modules/@tanstack*'
42
- ```
33
+ This package publishes TypeScript source. Node-based SSR bundlers should bundle
34
+ it instead of externalizing it; for Vite:
43
35
 
44
- A clean `rm -rf node_modules && bun install` removes any that a normal
45
- install wouldn't have produced on its own.
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.16.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.2",
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
+ "orpc",
9
10
  "realtime",
10
- "sse",
11
11
  "sync",
12
12
  "tanstack-db"
13
13
  ],
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "src",
23
23
  "!src/**/*.test.ts",
24
+ "!src/**/*.types.ts",
24
25
  "README.md",
25
26
  "LICENSE"
26
27
  ],
@@ -35,7 +36,7 @@
35
36
  "test": "bun test"
36
37
  },
37
38
  "dependencies": {
38
- "bunderstack-query": "^0.16.0"
39
+ "bunderstack-query": "^0.17.0-beta.2"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@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
- import {
10
- createTableClient,
11
- MAX_LIST_LIMIT,
12
- type TableClient,
13
- } 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
+ }
14
46
 
15
47
  export type TableCollectionConfig = {
16
48
  tableName: string
17
- baseUrl: string
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`. */
@@ -31,8 +62,9 @@ export type ScopedFilterValue =
31
62
 
32
63
  export type ScopedCollectionOptions = {
33
64
  /** Equality filters, e.g. `{ replyToId: null }` — columns must be in the
34
- * table's `filterableColumns` server-side. */
35
- filter?: Record<string, ScopedFilterValue>
65
+ * table's `filterableColumns` server-side. Same name and shape as the
66
+ * `filters` accepted by the list procedure. */
67
+ filters?: Record<string, ScopedFilterValue>
36
68
  sort?: string
37
69
  order?: 'asc' | 'desc'
38
70
  /** Rows per underlying request; clamped to the server cap (200). */
@@ -43,9 +75,9 @@ export type ScopedCollectionOptions = {
43
75
 
44
76
  function matchesFilter(
45
77
  record: Record<string, unknown>,
46
- filter: Record<string, ScopedFilterValue>,
78
+ filters: Record<string, ScopedFilterValue>,
47
79
  ): boolean {
48
- for (const [col, expected] of Object.entries(filter)) {
80
+ for (const [col, expected] of Object.entries(filters)) {
49
81
  const actual = record[col]
50
82
  if (expected === null) {
51
83
  if (actual != null) return false
@@ -71,48 +103,98 @@ export function createTableCollection<
71
103
  TCreate = Partial<TRow>,
72
104
  TUpdate = Partial<TRow>,
73
105
  >(config: TableCollectionConfig) {
74
- const table = createTableClient<TRow, TCreate, TUpdate>({
75
- tableName: config.tableName,
76
- baseUrl: config.baseUrl,
77
- fetch: config.fetch,
78
- })
106
+ const table = config.procedures as TableProcedures<TRow, TCreate, TUpdate>
107
+ const direct: DirectTableApi<TRow, TCreate, TUpdate> = {
108
+ list: (input = {}) => table.list.call(input),
109
+ get: (id) => table.get.call({ id: String(id) }),
110
+ create: (input) => table.create.call(input),
111
+ update: (id, input) =>
112
+ table.update.call({
113
+ params: { id: String(id) },
114
+ query: {},
115
+ headers: {},
116
+ body: input,
117
+ }),
118
+ delete: (id) => table.delete.call({ id: String(id) }),
119
+ }
79
120
 
80
121
  const collection = createCollection(
81
122
  queryCollectionOptions<TRow>({
82
123
  queryKey: [config.tableName, 'collection'],
83
124
  queryFn: async () => {
84
- const page = await table.list({ limit: config.limit ?? 100 })
125
+ const page = await table.list.call({ limit: config.limit ?? 100 })
85
126
  return page.items
86
127
  },
87
128
  queryClient: config.queryClient,
88
129
  getKey: (item) => item.id,
89
130
  onInsert: async ({ transaction }) => {
90
- const mutation = transaction.mutations[0]!
91
- // Pass the client-generated `id` through as-is. TanStack DB's
92
- // optimistic insert keys the local row by this `id` (via `getKey`),
93
- // and this matches `sanitizeWriteBody`'s default on the server: a
94
- // client-supplied `id` on create is accepted unless the table's
95
- // access config sets an explicit `writableColumns` allowlist that
96
- // excludes `id`. Apps that DO restrict it that way will see the
97
- // server regenerate the id, and the optimistic entry's key will get
98
- // swapped once the synced row comes back — a known, narrower
99
- // trade-off in that uncommon case, not the default.
100
- await table.create(mutation.modified as unknown as Partial<TCreate>)
131
+ let reconciled = true
132
+ for (const mutation of transaction.mutations) {
133
+ // Pass the client-generated `id` through as-is. TanStack DB's
134
+ // optimistic insert keys the local row by this `id` (via `getKey`),
135
+ // and this matches `sanitizeWriteBody`'s default on the server: a
136
+ // client-supplied `id` on create is accepted unless the table's
137
+ // access config sets an explicit `writableColumns` allowlist that
138
+ // excludes `id`.
139
+ const row = (await table.create.call(
140
+ mutation.modified as unknown as TCreate,
141
+ )) as TRow | undefined
142
+ // Apps that DO restrict it that way get a server-assigned id, which
143
+ // leaves the optimistic row keyed under the client's — `writeUpsert`
144
+ // can't retire that, so those fall back to the refetch.
145
+ if (row?.id != null && String(row.id) === String(mutation.key)) {
146
+ applyRealtimeEvent('create', row as Record<string, unknown>)
147
+ } else {
148
+ reconciled = false
149
+ }
150
+ }
151
+ return reconciled ? { refetch: false } : {}
101
152
  },
102
153
  onUpdate: async ({ transaction }) => {
103
- const mutation = transaction.mutations[0]!
104
- await table.update(
105
- mutation.key as string | number,
106
- mutation.changes as unknown as TUpdate,
154
+ await Promise.all(
155
+ transaction.mutations.map((mutation) =>
156
+ updateQueue.enqueue(
157
+ mutation.key as TRow['id'],
158
+ mutation.changes as Record<string, unknown>,
159
+ ),
160
+ ),
107
161
  )
162
+ return { refetch: false }
108
163
  },
109
164
  onDelete: async ({ transaction }) => {
110
- const mutation = transaction.mutations[0]!
111
- await table.delete(mutation.key as string | number)
165
+ for (const mutation of transaction.mutations) {
166
+ // Updates still queued for this row are superseded, and one already
167
+ // in flight must land before the DELETE or the two race.
168
+ await updateQueue.settle(mutation.key as TRow['id'])
169
+ await table.delete.call({ id: String(mutation.key) })
170
+ applyRealtimeEvent('delete', { id: mutation.key })
171
+ }
172
+ return { refetch: false }
112
173
  },
113
174
  }),
114
175
  )
115
176
 
177
+ // Cursor-shaped workloads call `update()` many times a second. The queue
178
+ // merges everything that piles up for a row while its request is in flight
179
+ // into a single follow-up, so request rate tracks RTT, not call rate.
180
+ const updateQueue = createUpdateQueue<TRow['id'], TRow>({
181
+ send: (id, changes) =>
182
+ table.update.call({
183
+ params: { id: String(id) },
184
+ query: {},
185
+ headers: {},
186
+ body: changes as unknown as TUpdate,
187
+ }),
188
+ onResult: async (row) => {
189
+ // `writeUpsert` writes the response whole, so mutation endpoints are
190
+ // contracted to return a full row. A body without an id can't be
191
+ // reconciled locally — fall back to the refetch rather than write it.
192
+ if ((row as TRow | undefined)?.id == null)
193
+ await collection.utils.refetch()
194
+ else applyRealtimeEvent('update', row as Record<string, unknown>)
195
+ },
196
+ })
197
+
116
198
  type Collection = typeof collection
117
199
 
118
200
  // Scoped/byIds collections register here so realtime events fan out to
@@ -144,9 +226,9 @@ export function createTableCollection<
144
226
  MAX_LIST_LIMIT,
145
227
  )
146
228
  const initialCount = options.initialCount ?? 20
147
- const filter = options.filter ?? {}
229
+ const filters = options.filters ?? {}
148
230
  const cacheKey = stableKey({
149
- filter,
231
+ filters,
150
232
  sort: options.sort ?? null,
151
233
  order: options.order ?? null,
152
234
  pageSize,
@@ -172,11 +254,10 @@ export function createTableCollection<
172
254
  let more = false
173
255
  while (items.length < desiredCount) {
174
256
  const remaining = Math.min(pageSize, desiredCount - items.length)
175
- const page = await table.list({
176
- ...filter,
257
+ const page = await table.list.call({
258
+ ...(Object.keys(filters).length ? { filters } : {}),
177
259
  ...(options.sort ? { sort: options.sort } : {}),
178
260
  ...(options.order ? { order: options.order } : {}),
179
- cursorMode: true,
180
261
  limit: remaining,
181
262
  ...(cursor ? { cursor } : {}),
182
263
  })
@@ -204,7 +285,7 @@ export function createTableCollection<
204
285
  }
205
286
  registry.push({
206
287
  collection: scoped,
207
- matches: (record) => matchesFilter(record, filter),
288
+ matches: (record) => matchesFilter(record, filters),
208
289
  refetch: async () => {
209
290
  await scoped.utils.refetch()
210
291
  },
@@ -235,8 +316,8 @@ export function createTableCollection<
235
316
  // Chunked at the server's IN-filter cap so any id set works.
236
317
  for (let i = 0; i < unique.length; i += MAX_LIST_LIMIT) {
237
318
  const chunk = unique.slice(i, i + MAX_LIST_LIMIT)
238
- const page = await table.list({
239
- [column]: chunk,
319
+ const page = await table.list.call({
320
+ filters: { [column]: chunk },
240
321
  limit: chunk.length,
241
322
  })
242
323
  items.push(...page.items)
@@ -281,7 +362,7 @@ export function createTableCollection<
281
362
  for (const entry of registry) apply(entry.collection, entry.matches)
282
363
  }
283
364
 
284
- /** Refetch the base collection plus every scoped/byIds view (gap recovery). */
365
+ /** Refetch the base collection plus every scoped/byIds view after reconnect. */
285
366
  async function refetchAll() {
286
367
  await Promise.all([
287
368
  collection.utils.refetch(),
@@ -291,7 +372,7 @@ export function createTableCollection<
291
372
 
292
373
  return {
293
374
  collection,
294
- table: table as TableClient<TRow, TCreate, TUpdate>,
375
+ table: direct,
295
376
  scopedCollection,
296
377
  collectionByIds,
297
378
  applyRealtimeEvent,
@@ -322,7 +403,7 @@ export type TableCollection<
322
403
  Record<string, any>,
323
404
  StandardSchema<TRow>
324
405
  >
325
- table: TableClient<TRow, TCreate, TUpdate>
406
+ table: DirectTableApi<TRow, TCreate, TUpdate>
326
407
  scopedCollection: (
327
408
  options?: ScopedCollectionOptions,
328
409
  ) => 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'
@@ -1,6 +1,9 @@
1
1
  import type { QueryClient } from '@tanstack/react-query'
2
-
3
- import { createRealtimeClient, type RealtimeEvent } from 'bunderstack-query'
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
- baseUrl: string
25
+ api: RealtimeQueryApi
24
26
  queryClient: QueryClient
25
- fetch?: typeof fetch
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(config: SyncRealtimeConfig) {
36
- const staticCollections = config.collections ?? {}
37
- const tables = Object.keys(staticCollections)
38
-
39
- return createRealtimeClient({
40
- baseUrl: config.baseUrl,
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
- fetch: config.fetch,
44
- applyEvent: (evt: RealtimeEvent) => {
41
+ tables: config.tables,
42
+ retryMs: config.retryMs,
43
+ onChange: (event) => {
45
44
  if (config.resolve) {
46
- config.resolve(evt.table)?.applyRealtimeEvent(evt.action, evt.record)
45
+ config.resolve(event.table)?.applyRealtimeEvent(event.action, event.record)
47
46
  return
48
47
  }
49
- const collection = staticCollections[evt.table]
48
+ const collection = collections[event.table]
50
49
  if (!collection) return
51
- if (evt.action === 'delete') {
52
- collection.utils.writeDelete(evt.record['id'])
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
- onGap: () => {
53
+ onReconnect: async () => {
58
54
  if (config.resolveAll) {
59
- for (const target of config.resolveAll()) {
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
- for (const collection of Object.values(staticCollections)) {
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
  }
@@ -1,12 +1,8 @@
1
1
  import type { QueryClient } from '@tanstack/react-query'
2
-
3
2
  import {
4
- attachBucketMutationOptions,
5
- createBucketClient,
6
- lazyRecord,
3
+ createClient,
7
4
  type AnyBunderstackApp,
8
- type FilesQueryClient,
9
- type InferBuckets,
5
+ type BunderstackClient,
10
6
  type InferInsert,
11
7
  type InferSchema,
12
8
  type InferSelect,
@@ -14,32 +10,27 @@ import {
14
10
  } from 'bunderstack-query'
15
11
 
16
12
  import { createTableCollection, type TableCollection } from './collection'
17
- import {
18
- createSyncRealtimeClient,
19
- type SyncRealtimeTarget,
20
- } from './realtime-sync'
13
+ import { createSyncRealtimeClient, type SyncRealtimeTarget } from './realtime-sync'
21
14
 
22
- export type RowFor<
23
- TSchema extends Record<string, unknown>,
24
- K extends keyof TSchema,
25
- > = [InferSelect<TSchema[K]>] extends [never]
26
- ? { id: string | number }
27
- : InferSelect<TSchema[K]> extends { id: string | number }
28
- ? InferSelect<TSchema[K]>
29
- : { id: string | number }
15
+ export type RowFor<TSchema extends Record<string, unknown>, K extends keyof TSchema> =
16
+ [InferSelect<TSchema[K]>] extends [never]
17
+ ? { id: string | number }
18
+ : InferSelect<TSchema[K]> extends { id: string | number }
19
+ ? InferSelect<TSchema[K]>
20
+ : { id: string | number }
30
21
 
31
- export type CreateFor<
32
- TSchema extends Record<string, unknown>,
33
- K extends keyof TSchema,
34
- > = [InferInsert<TSchema[K]>] extends [never]
35
- ? Partial<RowFor<TSchema, K>>
36
- : InferInsert<TSchema[K]>
22
+ export type CreateFor<TSchema extends Record<string, unknown>, K extends keyof TSchema> =
23
+ [InferInsert<TSchema[K]>] extends [never]
24
+ ? Partial<RowFor<TSchema, K>>
25
+ : InferInsert<TSchema[K]>
37
26
 
38
27
  export type SyncClientOptions = {
39
28
  baseUrl?: string
40
- fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
29
+ fetch?: (
30
+ input: RequestInfo | URL,
31
+ init?: RequestInit,
32
+ ) => Promise<Response>
41
33
  queryClient: QueryClient
42
- /** Live SSE updates. Defaults to true in the browser, false during SSR. */
43
34
  realtime?: boolean
44
35
  }
45
36
 
@@ -49,80 +40,58 @@ export type BunderstackSyncClient<TApp extends AnyBunderstackApp> = {
49
40
  CreateFor<InferSchema<TApp>, K>,
50
41
  Partial<RowFor<InferSchema<TApp>, K>>
51
42
  >
52
- } & FilesQueryClient<InferBuckets<TApp>> & {
53
- realtime: ReturnType<typeof createSyncRealtimeClient> | undefined
54
- }
43
+ } & {
44
+ files: BunderstackClient<TApp>['files']
45
+ realtime: { close(): void; subscribe(tables: string[]): Promise<void> } | undefined
46
+ }
55
47
 
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
48
  export function createSyncClient<TApp extends AnyBunderstackApp>(
69
49
  options: SyncClientOptions,
70
50
  ): BunderstackSyncClient<TApp> {
71
- const baseUrl = options.baseUrl ?? '/api'
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.
51
+ const api = createClient<TApp>(options)
75
52
  const materialized = new Map<string, SyncRealtimeTarget>()
76
- const tables = lazyRecord((tableName) => {
77
- const bundle = createTableCollection({
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.
53
+ const realtimeHandles = new Map<string, { close(): void }>()
54
+ const tables = new Map<string, unknown>()
96
55
  const realtimeEnabled = options.realtime ?? typeof window !== 'undefined'
97
- const realtime = realtimeEnabled
98
- ? createSyncRealtimeClient({
99
- baseUrl,
56
+
57
+ const result = new Proxy({} as BunderstackSyncClient<TApp>, {
58
+ get(_target, property) {
59
+ if (typeof property !== 'string') return undefined
60
+ if (property === 'files') return api.files
61
+ if (property === 'realtime') {
62
+ return realtimeEnabled
63
+ ? {
64
+ close: () => realtimeHandles.forEach((handle) => handle.close()),
65
+ subscribe: async (names: string[]) => {
66
+ for (const name of names) void (result as any)[name]
67
+ },
68
+ }
69
+ : undefined
70
+ }
71
+ if (['then', 'toJSON', 'constructor', '$$typeof'].includes(property)) return undefined
72
+ const cached = tables.get(property)
73
+ if (cached) return cached
74
+ const collection = createTableCollection({
75
+ tableName: property,
76
+ procedures: (api as any)[property],
100
77
  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
78
  })
108
- : undefined
109
-
110
- return new Proxy({} as BunderstackSyncClient<TApp>, {
111
- get(_target, prop) {
112
- if (typeof prop !== 'string') return undefined
113
- if (prop === 'files') return files
114
- if (prop === 'realtime') return realtime
115
- if (
116
- prop === 'then' ||
117
- prop === 'toJSON' ||
118
- prop === 'constructor' ||
119
- prop === '$$typeof'
120
- )
121
- return undefined
122
- return (tables as Record<string, unknown>)[prop]
123
- },
124
- has(_target, prop) {
125
- return typeof prop === 'string'
79
+ tables.set(property, collection)
80
+ materialized.set(property, collection)
81
+ if (realtimeEnabled) {
82
+ realtimeHandles.set(
83
+ property,
84
+ createSyncRealtimeClient({
85
+ api: api as any,
86
+ queryClient: options.queryClient,
87
+ tables: [property],
88
+ resolve: (table) => materialized.get(table),
89
+ resolveAll: () => materialized.values(),
90
+ }),
91
+ )
92
+ }
93
+ return collection
126
94
  },
127
- }) as BunderstackSyncClient<TApp>
95
+ })
96
+ return result
128
97
  }
@@ -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
+ }