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 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
+ filter: { 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.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.16.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
- 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`. */
@@ -71,48 +102,110 @@ export function createTableCollection<
71
102
  TCreate = Partial<TRow>,
72
103
  TUpdate = Partial<TRow>,
73
104
  >(config: TableCollectionConfig) {
74
- const table = createTableClient<TRow, TCreate, TUpdate>({
75
- tableName: config.tableName,
76
- baseUrl: config.baseUrl,
77
- fetch: config.fetch,
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
- 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>)
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
- const mutation = transaction.mutations[0]!
104
- await table.update(
105
- mutation.key as string | number,
106
- 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
+ ),
107
172
  )
173
+ return { refetch: false }
108
174
  },
109
175
  onDelete: async ({ transaction }) => {
110
- const mutation = transaction.mutations[0]!
111
- 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 }
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
- ...filter,
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 (gap recovery). */
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: table as TableClient<TRow, TCreate, TUpdate>,
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: TableClient<TRow, TCreate, TUpdate>
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'
@@ -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,11 +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,
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 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 }
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 Record<string, unknown>,
33
- K extends keyof TSchema,
34
- > = [InferInsert<TSchema[K]>] extends [never]
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?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
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
- } & FilesQueryClient<InferBuckets<TApp>> & {
53
- realtime: ReturnType<typeof createSyncRealtimeClient> | undefined
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 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.
52
+ const api = createClient<TApp>(options)
75
53
  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.
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
- const realtime = realtimeEnabled
98
- ? createSyncRealtimeClient({
99
- 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],
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
- : 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'
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
- }) as BunderstackSyncClient<TApp>
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
+ }