bunderstack-sync 0.17.0-beta.0 → 0.17.0-beta.3

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.
@@ -0,0 +1,66 @@
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
+ export function createUpdateQueue(config) {
12
+ const slots = new Map();
13
+ async function drain(key, slot) {
14
+ while (Object.keys(slot.changes).length > 0) {
15
+ // Taking changes and waiters as one snapshot is what guarantees a waiter
16
+ // resolves only after a request that carried its own changes.
17
+ const changes = slot.changes;
18
+ const waiters = slot.waiters;
19
+ slot.changes = {};
20
+ slot.waiters = [];
21
+ try {
22
+ await config.onResult(await config.send(key, changes));
23
+ for (const waiter of waiters)
24
+ waiter.resolve();
25
+ }
26
+ catch (error) {
27
+ // Replaying queued changes on top of a failed base would diverge from
28
+ // the server silently, so the whole key is rolled back instead.
29
+ const queued = slot.waiters;
30
+ slot.changes = {};
31
+ slot.waiters = [];
32
+ for (const waiter of [...waiters, ...queued])
33
+ waiter.reject(error);
34
+ }
35
+ }
36
+ slots.delete(key);
37
+ }
38
+ return {
39
+ enqueue(key, changes) {
40
+ let slot = slots.get(key);
41
+ if (!slot) {
42
+ slot = { changes: {}, waiters: [], done: null };
43
+ slots.set(key, slot);
44
+ }
45
+ Object.assign(slot.changes, changes);
46
+ const promise = new Promise((resolve, reject) => {
47
+ slot.waiters.push({ resolve, reject });
48
+ });
49
+ if (!slot.done)
50
+ slot.done = drain(key, slot);
51
+ return promise;
52
+ },
53
+ settle(key) {
54
+ const slot = slots.get(key);
55
+ if (!slot)
56
+ return Promise.resolve();
57
+ const waiters = slot.waiters;
58
+ slot.changes = {};
59
+ slot.waiters = [];
60
+ for (const waiter of waiters)
61
+ waiter.resolve();
62
+ return slot.done ?? Promise.resolve();
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=update-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-queue.js","sourceRoot":"","sources":["../src/update-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AA+BH,MAAM,UAAU,iBAAiB,CAC/B,MAAqC;IAErC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAc,CAAA;IAEnC,KAAK,UAAU,KAAK,CAAC,GAAS,EAAE,IAAU;QACxC,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC5C,yEAAyE;YACzE,8DAA8D;YAC9D,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAA;YAC5B,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAA;YAC5B,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;YACjB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;YACjB,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAA;gBACtD,KAAK,MAAM,MAAM,IAAI,OAAO;oBAAE,MAAM,CAAC,OAAO,EAAE,CAAA;YAChD,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,sEAAsE;gBACtE,gEAAgE;gBAChE,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAA;gBAC3B,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;gBACjB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;gBACjB,KAAK,MAAM,MAAM,IAAI,CAAC,GAAG,OAAO,EAAE,GAAG,MAAM,CAAC;oBAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACpE,CAAC;QACH,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IACnB,CAAC;IAED,OAAO;QACL,OAAO,CAAC,GAAG,EAAE,OAAO;YAClB,IAAI,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACzB,IAAI,CAAC,IAAI,EAAE,CAAC;gBACV,IAAI,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;gBAC/C,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;YACtB,CAAC;YACD,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;YACpC,MAAM,OAAO,GAAG,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;gBACpD,IAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAA;YACzC,CAAC,CAAC,CAAA;YACF,IAAI,CAAC,IAAI,CAAC,IAAI;gBAAE,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;YAC5C,OAAO,OAAO,CAAA;QAChB,CAAC;QACD,MAAM,CAAC,GAAG;YACR,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YAC3B,IAAI,CAAC,IAAI;gBAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAA;YACnC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAA;YAC5B,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;YACjB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAA;YACjB,KAAK,MAAM,MAAM,IAAI,OAAO;gBAAE,MAAM,CAAC,OAAO,EAAE,CAAA;YAC9C,OAAO,IAAI,CAAC,IAAI,IAAI,OAAO,CAAC,OAAO,EAAE,CAAA;QACvC,CAAC;KACF,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "bunderstack-sync",
3
- "version": "0.17.0-beta.0",
3
+ "version": "0.17.0-beta.3",
4
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
- "realtime",
10
9
  "orpc",
10
+ "realtime",
11
11
  "sync",
12
12
  "tanstack-db"
13
13
  ],
@@ -19,23 +19,27 @@
19
19
  "directory": "packages/bunderstack-sync"
20
20
  },
21
21
  "files": [
22
- "src",
23
- "!src/**/*.test.ts",
22
+ "dist",
24
23
  "README.md",
25
24
  "LICENSE"
26
25
  ],
27
26
  "type": "module",
28
- "main": "./src/index.ts",
29
- "module": "./src/index.ts",
30
- "types": "./src/index.ts",
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
31
30
  "exports": {
32
- ".": "./src/index.ts"
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
33
35
  },
34
36
  "scripts": {
37
+ "build": "bun ../../scripts/build-package.ts bunderstack-sync",
38
+ "prepack": "bun run build",
35
39
  "test": "bun test"
36
40
  },
37
41
  "dependencies": {
38
- "bunderstack-query": "^0.17.0-beta.0"
42
+ "bunderstack-query": "^0.17.0-beta.3"
39
43
  },
40
44
  "devDependencies": {
41
45
  "@tanstack/db": "0.6.16",
package/src/collection.ts DELETED
@@ -1,435 +0,0 @@
1
- import type { QueryClient } from '@tanstack/react-query'
2
-
3
- import {
4
- createCollection,
5
- type Collection,
6
- type StandardSchema,
7
- } from '@tanstack/db'
8
- import { queryCollectionOptions } from '@tanstack/query-db-collection'
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
- }
46
-
47
- export type TableCollectionConfig = {
48
- tableName: string
49
- procedures: TableProcedures<any, any, any>
50
- queryClient: QueryClient
51
- /** Rows the default `.collection` syncs per fetch. Defaults to 100. For
52
- * feed-shaped tables that need real pagination use `scopedCollection`. */
53
- limit?: number
54
- }
55
-
56
- export type ScopedFilterValue =
57
- | string
58
- | number
59
- | boolean
60
- | null
61
- | readonly (string | number)[]
62
-
63
- export type ScopedCollectionOptions = {
64
- /** Equality filters, e.g. `{ replyToId: null }` — columns must be in the
65
- * table's `filterableColumns` server-side. */
66
- filter?: Record<string, ScopedFilterValue>
67
- sort?: string
68
- order?: 'asc' | 'desc'
69
- /** Rows per underlying request; clamped to the server cap (200). */
70
- pageSize?: number
71
- /** Window size on first load and default `loadMore` step. Defaults to 20. */
72
- initialCount?: number
73
- }
74
-
75
- function matchesFilter(
76
- record: Record<string, unknown>,
77
- filter: Record<string, ScopedFilterValue>,
78
- ): boolean {
79
- for (const [col, expected] of Object.entries(filter)) {
80
- const actual = record[col]
81
- if (expected === null) {
82
- if (actual != null) return false
83
- } else if (Array.isArray(expected)) {
84
- if (!expected.map(String).includes(String(actual))) return false
85
- } else if (String(actual) !== String(expected)) return false
86
- }
87
- return true
88
- }
89
-
90
- /** Deterministic serialization for cache keys (object key order ignored). */
91
- function stableKey(value: unknown): string {
92
- if (value === null || typeof value !== 'object') return JSON.stringify(value)
93
- if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`
94
- const entries = Object.entries(value as Record<string, unknown>)
95
- .sort(([a], [b]) => (a < b ? -1 : 1))
96
- .map(([k, v]) => `${JSON.stringify(k)}:${stableKey(v)}`)
97
- return `{${entries.join(',')}}`
98
- }
99
-
100
- export function createTableCollection<
101
- TRow extends { id: string | number },
102
- TCreate = Partial<TRow>,
103
- TUpdate = Partial<TRow>,
104
- >(config: TableCollectionConfig) {
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
- }
131
-
132
- const collection = createCollection(
133
- queryCollectionOptions<TRow>({
134
- queryKey: [config.tableName, 'collection'],
135
- queryFn: async () => {
136
- const page = await table.list.call({ limit: config.limit ?? 100 })
137
- return page.items
138
- },
139
- queryClient: config.queryClient,
140
- getKey: (item) => item.id,
141
- onInsert: async ({ transaction }) => {
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 } : {}
163
- },
164
- onUpdate: async ({ transaction }) => {
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
- ),
172
- )
173
- return { refetch: false }
174
- },
175
- onDelete: async ({ transaction }) => {
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 }
184
- },
185
- }),
186
- )
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
-
209
- type Collection = typeof collection
210
-
211
- // Scoped/byIds collections register here so realtime events fan out to
212
- // every live view of this table, filtered client-side by each view's
213
- // own predicate (broadcasts are already access-filtered server-side).
214
- const registry: {
215
- collection: Collection
216
- matches: (record: Record<string, unknown>) => boolean
217
- refetch: () => Promise<void>
218
- }[] = []
219
-
220
- const scopedCache = new Map<string, ScopedCollection>()
221
-
222
- type ScopedCollection = {
223
- collection: Collection
224
- /** Grow the window by `count` (default initialCount) and refetch in place. */
225
- loadMore: (count?: number) => Promise<void>
226
- /** Whether the server reported rows beyond the window (as of last fetch). */
227
- hasMore: () => boolean
228
- /** Current desired window size. */
229
- size: () => number
230
- }
231
-
232
- function scopedCollection(
233
- options: ScopedCollectionOptions = {},
234
- ): ScopedCollection {
235
- const pageSize = Math.min(
236
- options.pageSize ?? MAX_LIST_LIMIT,
237
- MAX_LIST_LIMIT,
238
- )
239
- const initialCount = options.initialCount ?? 20
240
- const filter = options.filter ?? {}
241
- const cacheKey = stableKey({
242
- filter,
243
- sort: options.sort ?? null,
244
- order: options.order ?? null,
245
- pageSize,
246
- initialCount,
247
- })
248
- const cached = scopedCache.get(cacheKey)
249
- if (cached) return cached
250
-
251
- let desiredCount = initialCount
252
- let serverHasMore = false
253
-
254
- const scoped = createCollection(
255
- queryCollectionOptions<TRow>({
256
- queryKey: [config.tableName, 'scoped', cacheKey],
257
- queryFn: async () => {
258
- // Growing window: walk cursor pages (each ≤ the server cap) until
259
- // the current desired count is collected or the table runs out.
260
- // The collection stays stable across loadMore — refetching in
261
- // place only ever adds rows, so already-rendered items never
262
- // unmount (no scroll jumps or zero-item flashes).
263
- const items: TRow[] = []
264
- let cursor: string | undefined
265
- let more = false
266
- while (items.length < desiredCount) {
267
- const remaining = Math.min(pageSize, desiredCount - items.length)
268
- const page = await table.list.call({
269
- filters: filter,
270
- ...(options.sort ? { sort: options.sort } : {}),
271
- ...(options.order ? { order: options.order } : {}),
272
- limit: remaining,
273
- ...(cursor ? { cursor } : {}),
274
- })
275
- items.push(...page.items)
276
- more = Boolean(page.hasMore && page.nextCursor)
277
- if (!more) break
278
- cursor = page.nextCursor
279
- }
280
- serverHasMore = more
281
- return items.slice(0, desiredCount)
282
- },
283
- queryClient: config.queryClient,
284
- getKey: (item) => item.id,
285
- }),
286
- )
287
-
288
- const entry: ScopedCollection = {
289
- collection: scoped,
290
- loadMore: async (count) => {
291
- desiredCount += count ?? initialCount
292
- await scoped.utils.refetch()
293
- },
294
- hasMore: () => serverHasMore,
295
- size: () => desiredCount,
296
- }
297
- registry.push({
298
- collection: scoped,
299
- matches: (record) => matchesFilter(record, filter),
300
- refetch: async () => {
301
- await scoped.utils.refetch()
302
- },
303
- })
304
- scopedCache.set(cacheKey, entry)
305
- return entry
306
- }
307
-
308
- const byIdsCache = new Map<string, Collection>()
309
-
310
- function collectionByIds(
311
- ids: readonly TRow['id'][],
312
- options: { column?: string } = {},
313
- ): Collection {
314
- const column = options.column ?? 'id'
315
- const unique = Array.from(new Set(ids.map(String))).sort()
316
- const cacheKey = `${column}:${unique.join(',')}`
317
- const cached = byIdsCache.get(cacheKey)
318
- if (cached) return cached
319
-
320
- const idSet = new Set(unique)
321
- const byIds = createCollection(
322
- queryCollectionOptions<TRow>({
323
- queryKey: [config.tableName, 'byIds', column, unique],
324
- queryFn: async () => {
325
- if (unique.length === 0) return []
326
- const items: TRow[] = []
327
- // Chunked at the server's IN-filter cap so any id set works.
328
- for (let i = 0; i < unique.length; i += MAX_LIST_LIMIT) {
329
- const chunk = unique.slice(i, i + MAX_LIST_LIMIT)
330
- const page = await table.list.call({
331
- filters: { [column]: chunk },
332
- limit: chunk.length,
333
- })
334
- items.push(...page.items)
335
- }
336
- return items
337
- },
338
- queryClient: config.queryClient,
339
- getKey: (item) => item.id,
340
- }),
341
- )
342
- registry.push({
343
- collection: byIds,
344
- matches: (record) => idSet.has(String(record[column])),
345
- refetch: async () => {
346
- await byIds.utils.refetch()
347
- },
348
- })
349
- byIdsCache.set(cacheKey, byIds)
350
- return byIds
351
- }
352
-
353
- function applyRealtimeEvent(
354
- action: 'create' | 'update' | 'delete',
355
- record: Record<string, unknown>,
356
- ) {
357
- const id = record['id'] as string | number
358
- const apply = (
359
- target: Collection,
360
- matches: (record: Record<string, unknown>) => boolean,
361
- ) => {
362
- // Collections that never started syncing (no subscribers yet) can't
363
- // accept manual writes — and don't need to: their first sync fetches
364
- // fresh data anyway.
365
- if (target.status !== 'ready') return
366
- if (action === 'delete' || !matches(record)) {
367
- if (target.get(id) !== undefined) target.utils.writeDelete(id)
368
- } else {
369
- target.utils.writeUpsert(record as unknown as TRow)
370
- }
371
- }
372
- apply(collection, () => true)
373
- for (const entry of registry) apply(entry.collection, entry.matches)
374
- }
375
-
376
- /** Refetch the base collection plus every scoped/byIds view after reconnect. */
377
- async function refetchAll() {
378
- await Promise.all([
379
- collection.utils.refetch(),
380
- ...registry.map((entry) => entry.refetch()),
381
- ])
382
- }
383
-
384
- return {
385
- collection,
386
- table: direct,
387
- scopedCollection,
388
- collectionByIds,
389
- applyRealtimeEvent,
390
- refetchAll,
391
- }
392
- }
393
-
394
- export type ScopedCollection<TRow extends { id: string | number }> = {
395
- collection: Collection<
396
- TRow,
397
- string | number,
398
- Record<string, any>,
399
- StandardSchema<TRow>
400
- >
401
- loadMore: (count?: number) => Promise<void>
402
- hasMore: () => boolean
403
- size: () => number
404
- }
405
-
406
- export type TableCollection<
407
- TRow extends { id: string | number },
408
- TCreate = Partial<TRow>,
409
- TUpdate = Partial<TRow>,
410
- > = {
411
- collection: Collection<
412
- TRow,
413
- string | number,
414
- Record<string, any>,
415
- StandardSchema<TRow>
416
- >
417
- table: DirectTableApi<TRow, TCreate, TUpdate>
418
- scopedCollection: (
419
- options?: ScopedCollectionOptions,
420
- ) => ScopedCollection<TRow>
421
- collectionByIds: (
422
- ids: readonly (string | number)[],
423
- column?: string,
424
- ) => Collection<
425
- TRow,
426
- string | number,
427
- Record<string, any>,
428
- StandardSchema<TRow>
429
- >
430
- applyRealtimeEvent: (
431
- action: 'create' | 'update' | 'delete',
432
- record: Record<string, unknown>,
433
- ) => void
434
- refetchAll: () => Promise<void>
435
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- export { createSyncClient } from './sync-client'
2
- export type { BunderstackSyncClient, CreateFor, RowFor, SyncClientOptions } from './sync-client'
3
- export { createTableCollection } from './collection'
4
- export type { ScopedCollectionOptions, ScopedFilterValue, TableCollection, TableCollectionConfig } from './collection'
5
- export { createSyncRealtimeClient } from './realtime-sync'
6
- export type { SyncRealtimeConfig, SyncRealtimeTarget } from './realtime-sync'
7
- export type { AnyBunderstackApp, InferBuckets, InferInsert, InferSchema, InferSelect, InferTables, UploadedFile } from 'bunderstack-query'
@@ -1,61 +0,0 @@
1
- import type { QueryClient } from '@tanstack/react-query'
2
- import {
3
- syncRealtime,
4
- type RealtimeQueryApi,
5
- type RealtimeSyncHandle,
6
- } from 'bunderstack-query'
7
-
8
- type SyncableCollection = {
9
- utils: {
10
- writeUpsert: (item: unknown) => void
11
- writeDelete: (key: unknown) => void
12
- refetch: () => Promise<void>
13
- }
14
- }
15
-
16
- export type SyncRealtimeTarget = {
17
- applyRealtimeEvent: (
18
- action: 'create' | 'update' | 'delete',
19
- record: Record<string, unknown>,
20
- ) => void
21
- refetchAll: () => Promise<void>
22
- }
23
-
24
- export type SyncRealtimeConfig = {
25
- api: RealtimeQueryApi
26
- queryClient: QueryClient
27
- tables: string[]
28
- collections?: Record<string, SyncableCollection>
29
- resolve?: (table: string) => SyncRealtimeTarget | undefined
30
- resolveAll?: () => Iterable<SyncRealtimeTarget>
31
- retryMs?: number
32
- }
33
-
34
- export function createSyncRealtimeClient(
35
- config: SyncRealtimeConfig,
36
- ): RealtimeSyncHandle {
37
- const collections = config.collections ?? {}
38
- return syncRealtime({
39
- api: config.api,
40
- queryClient: config.queryClient,
41
- tables: config.tables,
42
- retryMs: config.retryMs,
43
- onChange: (event) => {
44
- if (config.resolve) {
45
- config.resolve(event.table)?.applyRealtimeEvent(event.action, event.record)
46
- return
47
- }
48
- const collection = collections[event.table]
49
- if (!collection) return
50
- if (event.action === 'delete') collection.utils.writeDelete(event.record['id'])
51
- else collection.utils.writeUpsert(event.record)
52
- },
53
- onReconnect: async () => {
54
- if (config.resolveAll) {
55
- await Promise.all([...config.resolveAll()].map((target) => target.refetchAll()))
56
- return
57
- }
58
- await Promise.all(Object.values(collections).map((collection) => collection.utils.refetch()))
59
- },
60
- })
61
- }