@pylo/react 0.10.0 → 0.11.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.
@@ -1,14 +1,131 @@
1
1
  import {
2
+ usePyloRealtime,
2
3
  usePyloTransport
3
- } from "./chunk-BPRHGSZF.js";
4
+ } from "./chunk-OPMVPQER.js";
5
+
6
+ // src/hooks.ts
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+
9
+ // src/live.ts
10
+ var IMMUTABLE_SORT_FIELDS = /* @__PURE__ */ new Set(["id", "integer_id", "created_at"]);
11
+ function applyRowEvent(row, event) {
12
+ if (event.removed) return { data: null, refetch: false };
13
+ return { data: { ...row, ...event.data }, refetch: false };
14
+ }
15
+ function applyListEvent(list, event, sortby, placement) {
16
+ const result = applyPagesEvent({ pages: [list], pageParams: [1] }, event, sortby, placement);
17
+ return { data: result.data.pages[0], refetch: result.refetch };
18
+ }
19
+ function applyPagesEvent(data, event, sortby, placement = { firstPage: true, keepOverflow: true }) {
20
+ const pageIndex = data.pages.findIndex((page) => page.data.some((row) => row.id === event.id));
21
+ if (event.removed) {
22
+ if (pageIndex === -1) return { data, refetch: false };
23
+ return {
24
+ data: updatePage(
25
+ data,
26
+ pageIndex,
27
+ (page) => adjustTotal({ ...page, data: page.data.filter((row) => row.id !== event.id) }, pageIndex, -1)
28
+ ),
29
+ refetch: "later"
30
+ };
31
+ }
32
+ const incoming = event.data;
33
+ if (pageIndex !== -1) {
34
+ const page = data.pages[pageIndex];
35
+ const current = page.data.find((row) => row.id === event.id);
36
+ const merged = { ...current, ...incoming };
37
+ const sort = sortChange(current, merged, sortby);
38
+ if (sort === "changed") return { data, refetch: "soon" };
39
+ return {
40
+ data: updatePage(data, pageIndex, (p) => ({
41
+ ...p,
42
+ data: p.data.map((row) => row.id === event.id ? merged : row)
43
+ })),
44
+ refetch: sort === "unknown" ? "later" : false
45
+ };
46
+ }
47
+ if (event.kind === "create" && isNewestFirst(sortby) && data.pages.length > 0) {
48
+ if (!placement.firstPage) return { data, refetch: "later" };
49
+ const inserted = updatePage(
50
+ data,
51
+ 0,
52
+ (page2) => adjustTotal({ ...page2, data: [incoming, ...page2.data] }, 0, 1)
53
+ );
54
+ if (placement.keepOverflow) return { data: inserted, refetch: false };
55
+ const { perPage } = placement;
56
+ if (perPage === void 0) return { data: inserted, refetch: "later" };
57
+ const page = inserted.pages[0];
58
+ if (page.data.length <= perPage) return { data: inserted, refetch: false };
59
+ const trimmed = recountPages({ ...page, data: page.data.slice(0, perPage) }, perPage);
60
+ if (!trimmed) return { data: inserted, refetch: "later" };
61
+ return { data: updatePage(inserted, 0, () => trimmed), refetch: false };
62
+ }
63
+ return { data, refetch: "later" };
64
+ }
65
+ function updatePage(data, index, update) {
66
+ return { ...data, pages: data.pages.map((page, i) => i === index ? update(page) : page) };
67
+ }
68
+ function recountPages(page, perPage) {
69
+ const pagination = page.pagination;
70
+ if (!pagination) return page;
71
+ const total = pagination.total;
72
+ const dependsOnTotal = "last_page" in pagination || "has_more_pages" in pagination;
73
+ if (!dependsOnTotal) return page;
74
+ if (typeof total !== "number") return null;
75
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
76
+ return {
77
+ ...page,
78
+ pagination: {
79
+ ...pagination,
80
+ ..."last_page" in pagination ? { last_page: lastPage } : {},
81
+ ..."has_more_pages" in pagination ? { has_more_pages: lastPage > 1 } : {}
82
+ }
83
+ };
84
+ }
85
+ function adjustTotal(page, pageIndex, delta) {
86
+ var _a;
87
+ const total = (_a = page.pagination) == null ? void 0 : _a.total;
88
+ if (pageIndex !== 0 || typeof total !== "number") return page;
89
+ return { ...page, pagination: { ...page.pagination, total: Math.max(0, total + delta) } };
90
+ }
91
+ function isNewestFirst(sortby) {
92
+ const first = sortby == null ? void 0 : sortby[0];
93
+ if (!first) return true;
94
+ return (first.field === "integer_id" || first.field === "created_at") && first.order === "desc";
95
+ }
96
+ function sortChange(before, after, sortby) {
97
+ let unknown = false;
98
+ for (const { field } of sortby != null ? sortby : []) {
99
+ if (IMMUTABLE_SORT_FIELDS.has(field)) continue;
100
+ const previous = readPath(before, field);
101
+ const next = readPath(after, field);
102
+ if (next === void 0) {
103
+ unknown = true;
104
+ continue;
105
+ }
106
+ if (JSON.stringify(previous) !== JSON.stringify(next)) return "changed";
107
+ }
108
+ return unknown ? "unknown" : "unchanged";
109
+ }
110
+ function readPath(row, path) {
111
+ let value = row;
112
+ for (const key of path.split(".")) {
113
+ if (value && typeof value === "object" && "data" in value && !(key in value)) {
114
+ value = value.data;
115
+ }
116
+ if (!value || typeof value !== "object") return void 0;
117
+ value = value[key];
118
+ }
119
+ return value;
120
+ }
4
121
 
5
122
  // src/hooks.ts
6
- import { useCallback, useRef, useState } from "react";
7
123
  import {
8
124
  useQuery,
9
125
  useInfiniteQuery,
10
126
  useMutation,
11
- useQueryClient
127
+ useQueryClient,
128
+ hashKey
12
129
  } from "@tanstack/react-query";
13
130
  import {
14
131
  buildListQuery,
@@ -38,13 +155,91 @@ import {
38
155
  uploadToUrl,
39
156
  toUploadPart
40
157
  } from "@pylo/core";
158
+ function patchableWith(headers) {
159
+ return headers === void 0 || Object.keys(headers).length === 0;
160
+ }
161
+ function withId(select) {
162
+ return { ...select, id: true };
163
+ }
164
+ function dedupeById(rows) {
165
+ const seen = /* @__PURE__ */ new Set();
166
+ return rows.filter((row) => {
167
+ const id = row == null ? void 0 : row.id;
168
+ if (id === void 0) return true;
169
+ if (seen.has(id)) return false;
170
+ seen.add(id);
171
+ return true;
172
+ });
173
+ }
174
+ var MAX_PAGES_TO_RESYNC = 5;
175
+ function invalidateEntity(queryClient, realtime, entity) {
176
+ const live = realtime !== null && realtime.client.isLive(entity);
177
+ return queryClient.invalidateQueries({
178
+ queryKey: ["pylo", entity],
179
+ ...live ? { predicate: (query) => !realtime.liveQueries.has(query.queryHash) } : {}
180
+ });
181
+ }
41
182
  var INFINITE_PAGINATION_SELECT = { current_page: true, has_more_pages: true };
42
183
  function createPyloHooks(options) {
43
- var _a;
184
+ var _a, _b;
44
185
  const useTransport = (_a = options == null ? void 0 : options.useTransport) != null ? _a : usePyloTransport;
186
+ const useRealtime = (_b = options == null ? void 0 : options.useRealtime) != null ? _b : usePyloRealtime;
45
187
  const globalHeaders = options == null ? void 0 : options.headers;
188
+ function useLive(entity, queryKey, subscription, apply, beforeResync) {
189
+ const realtime = useRealtime();
190
+ const queryClient = useQueryClient();
191
+ const keyHash = hashKey(queryKey);
192
+ const latest = useRef({ queryKey, subscription, apply, beforeResync });
193
+ latest.current = { queryKey, subscription, apply, beforeResync };
194
+ const enabled = realtime !== null && subscription !== null;
195
+ useEffect(() => {
196
+ var _a2;
197
+ if (!enabled) return;
198
+ const { queryKey: queryKey2, subscription: subscription2 } = latest.current;
199
+ let timer;
200
+ let fireAt = Infinity;
201
+ const scheduleRefetch = (delayMs) => {
202
+ const at = Date.now() + delayMs;
203
+ if (timer !== void 0 && fireAt <= at) return;
204
+ clearTimeout(timer);
205
+ fireAt = at;
206
+ timer = setTimeout(() => {
207
+ timer = void 0;
208
+ fireAt = Infinity;
209
+ void queryClient.invalidateQueries({ queryKey: queryKey2, exact: true });
210
+ }, delayMs);
211
+ };
212
+ const counts = realtime.liveQueries;
213
+ counts.set(keyHash, ((_a2 = counts.get(keyHash)) != null ? _a2 : 0) + 1);
214
+ const sub = realtime.client.subscribe(entity, subscription2, {
215
+ onEvent: (event) => {
216
+ let refetch = false;
217
+ queryClient.setQueryData(queryKey2, (old) => {
218
+ if (old === void 0) return old;
219
+ const result = latest.current.apply(old, event);
220
+ refetch = result.refetch;
221
+ return result.data;
222
+ });
223
+ if (refetch) scheduleRefetch(refetch === "soon" ? 250 : 1500);
224
+ },
225
+ onResync: () => {
226
+ var _a3, _b2;
227
+ (_b2 = (_a3 = latest.current).beforeResync) == null ? void 0 : _b2.call(_a3);
228
+ void queryClient.invalidateQueries({ queryKey: queryKey2, exact: true });
229
+ }
230
+ });
231
+ return () => {
232
+ var _a3;
233
+ clearTimeout(timer);
234
+ sub.unsubscribe();
235
+ const left = ((_a3 = counts.get(keyHash)) != null ? _a3 : 1) - 1;
236
+ if (left > 0) counts.set(keyHash, left);
237
+ else counts.delete(keyHash);
238
+ };
239
+ }, [enabled, realtime, queryClient, entity, keyHash]);
240
+ }
46
241
  function usePyloList(entity, queryOptions) {
47
- var _a2, _b, _c;
242
+ var _a2, _b2, _c;
48
243
  const transport = useTransport();
49
244
  const merged = mergeHeaders(globalHeaders, queryOptions == null ? void 0 : queryOptions.headers);
50
245
  const queryKey = [
@@ -61,17 +256,30 @@ function createPyloHooks(options) {
61
256
  const result = useQuery({
62
257
  queryKey,
63
258
  queryFn: async () => {
64
- const { query, variables } = buildListQuery(
65
- entity,
66
- queryOptions
67
- );
259
+ const { query, variables } = buildListQuery(entity, {
260
+ ...queryOptions,
261
+ select: withId(queryOptions.select)
262
+ });
68
263
  const data = await transport(query, variables, merged);
69
264
  const listKey = `${entity}List`;
70
265
  return data[listKey];
71
266
  }
72
267
  });
268
+ const patchable = patchableWith(merged);
269
+ useLive(
270
+ entity,
271
+ queryKey,
272
+ queryOptions.live ? { select: queryOptions.select, filter: queryOptions.filter } : null,
273
+ (data, event) => {
274
+ var _a3, _b3, _c2, _d;
275
+ return patchable ? applyListEvent(data, event, (_a3 = queryOptions.filter) == null ? void 0 : _a3.sortby, {
276
+ firstPage: ((_c2 = (_b3 = queryOptions.pagination) == null ? void 0 : _b3.page) != null ? _c2 : 1) === 1,
277
+ perPage: (_d = queryOptions.pagination) == null ? void 0 : _d.per_page
278
+ }) : { data, refetch: "later" };
279
+ }
280
+ );
73
281
  return {
74
- data: (_b = (_a2 = result.data) == null ? void 0 : _a2.data) != null ? _b : void 0,
282
+ data: (_b2 = (_a2 = result.data) == null ? void 0 : _a2.data) != null ? _b2 : void 0,
75
283
  ..."_pagination" in queryOptions.select && { pagination: (_c = result.data) == null ? void 0 : _c.pagination },
76
284
  isLoading: result.isLoading,
77
285
  error: result.error,
@@ -81,25 +289,41 @@ function createPyloHooks(options) {
81
289
  function usePyloInfiniteList(entity, infiniteOptions) {
82
290
  var _a2;
83
291
  const transport = useTransport();
292
+ const queryClient = useQueryClient();
84
293
  const perPage = (_a2 = infiniteOptions == null ? void 0 : infiniteOptions.perPage) != null ? _a2 : 20;
85
294
  const merged = mergeHeaders(globalHeaders, infiniteOptions == null ? void 0 : infiniteOptions.headers);
295
+ const queryKey = [
296
+ "pylo",
297
+ entity,
298
+ "infiniteList",
299
+ {
300
+ perPage,
301
+ filter: infiniteOptions == null ? void 0 : infiniteOptions.filter,
302
+ select: infiniteOptions == null ? void 0 : infiniteOptions.select,
303
+ headers: merged
304
+ }
305
+ ];
306
+ const patchable = patchableWith(merged);
307
+ useLive(
308
+ entity,
309
+ queryKey,
310
+ infiniteOptions.live ? { select: infiniteOptions.select, filter: infiniteOptions.filter } : null,
311
+ (data, event) => {
312
+ var _a3;
313
+ return patchable ? applyPagesEvent(data, event, (_a3 = infiniteOptions.filter) == null ? void 0 : _a3.sortby) : { data, refetch: "later" };
314
+ },
315
+ () => queryClient.setQueryData(
316
+ queryKey,
317
+ (old) => old && old.pages.length > MAX_PAGES_TO_RESYNC ? { pages: old.pages.slice(0, 1), pageParams: old.pageParams.slice(0, 1) } : old
318
+ )
319
+ );
86
320
  return useInfiniteQuery({
87
- queryKey: [
88
- "pylo",
89
- entity,
90
- "infiniteList",
91
- {
92
- perPage,
93
- filter: infiniteOptions == null ? void 0 : infiniteOptions.filter,
94
- select: infiniteOptions == null ? void 0 : infiniteOptions.select,
95
- headers: merged
96
- }
97
- ],
321
+ queryKey,
98
322
  queryFn: async ({ pageParam }) => {
99
323
  const select = infiniteOptions.select;
100
324
  const runtimeOptions = {
101
325
  select: {
102
- ...select,
326
+ ...withId(select),
103
327
  _pagination: { ...select._pagination, ...INFINITE_PAGINATION_SELECT }
104
328
  },
105
329
  filter: infiniteOptions == null ? void 0 : infiniteOptions.filter,
@@ -121,8 +345,8 @@ function createPyloHooks(options) {
121
345
  return void 0;
122
346
  },
123
347
  select: (queryData) => {
124
- const allData = queryData.pages.flatMap(
125
- (page) => page.data
348
+ const allData = dedupeById(
349
+ queryData.pages.flatMap((page) => page.data)
126
350
  );
127
351
  return {
128
352
  ...queryData,
@@ -135,14 +359,24 @@ function createPyloHooks(options) {
135
359
  function usePyloById(entity, id, queryOptions) {
136
360
  const transport = useTransport();
137
361
  const merged = mergeHeaders(globalHeaders, queryOptions == null ? void 0 : queryOptions.headers);
362
+ const queryKey = ["pylo", entity, "byId", id, { select: queryOptions == null ? void 0 : queryOptions.select, headers: merged }];
363
+ const patchable = patchableWith(merged);
364
+ useLive(
365
+ entity,
366
+ queryKey,
367
+ queryOptions.live && id ? {
368
+ select: queryOptions.select,
369
+ filter: { query: [{ condition: { field: "id", operator: "equal", value: id } }] }
370
+ } : null,
371
+ (data, event) => patchable ? applyRowEvent(data, event) : { data, refetch: "later" }
372
+ );
138
373
  return useQuery({
139
- queryKey: ["pylo", entity, "byId", id, { select: queryOptions == null ? void 0 : queryOptions.select, headers: merged }],
374
+ queryKey,
140
375
  queryFn: async () => {
141
- const { query, variables } = buildByIdQuery(
142
- entity,
143
- id,
144
- queryOptions
145
- );
376
+ const { query, variables } = buildByIdQuery(entity, id, {
377
+ ...queryOptions,
378
+ select: withId(queryOptions.select)
379
+ });
146
380
  const data = await transport(query, variables, merged);
147
381
  const byIdKey = `${entity}ById`;
148
382
  const result = data[byIdKey];
@@ -231,6 +465,7 @@ function createPyloHooks(options) {
231
465
  function usePyloUpsert(entity, mutationOptions) {
232
466
  const transport = useTransport();
233
467
  const queryClient = useQueryClient();
468
+ const realtime = useRealtime();
234
469
  const merged = mergeHeaders(
235
470
  mergeHeaders(globalHeaders, mutationOptions == null ? void 0 : mutationOptions.headers),
236
471
  flagsToHeaders(mutationOptions != null ? mutationOptions : {})
@@ -250,11 +485,7 @@ function createPyloHooks(options) {
250
485
  },
251
486
  onSuccess: (data, variables, onMutateResult, context) => {
252
487
  var _a2;
253
- if (!isDryRun) {
254
- void queryClient.invalidateQueries({
255
- queryKey: ["pylo", entity]
256
- });
257
- }
488
+ if (!isDryRun) void invalidateEntity(queryClient, realtime, entity);
258
489
  (_a2 = mutationOptions == null ? void 0 : mutationOptions.onSuccess) == null ? void 0 : _a2.call(mutationOptions, data, variables, onMutateResult, context);
259
490
  }
260
491
  });
@@ -262,6 +493,7 @@ function createPyloHooks(options) {
262
493
  function usePyloBulkUpsert(entity, mutationOptions) {
263
494
  const transport = useTransport();
264
495
  const queryClient = useQueryClient();
496
+ const realtime = useRealtime();
265
497
  const merged = mergeHeaders(
266
498
  mergeHeaders(globalHeaders, mutationOptions == null ? void 0 : mutationOptions.headers),
267
499
  flagsToHeaders(mutationOptions != null ? mutationOptions : {})
@@ -281,11 +513,7 @@ function createPyloHooks(options) {
281
513
  },
282
514
  onSuccess: (data, variables, onMutateResult, context) => {
283
515
  var _a2;
284
- if (!isDryRun) {
285
- void queryClient.invalidateQueries({
286
- queryKey: ["pylo", entity]
287
- });
288
- }
516
+ if (!isDryRun) void invalidateEntity(queryClient, realtime, entity);
289
517
  (_a2 = mutationOptions == null ? void 0 : mutationOptions.onSuccess) == null ? void 0 : _a2.call(mutationOptions, data, variables, onMutateResult, context);
290
518
  }
291
519
  });
@@ -293,6 +521,7 @@ function createPyloHooks(options) {
293
521
  function usePyloDelete(entity, mutationOptions) {
294
522
  const transport = useTransport();
295
523
  const queryClient = useQueryClient();
524
+ const realtime = useRealtime();
296
525
  const merged = mergeHeaders(
297
526
  mergeHeaders(globalHeaders, mutationOptions == null ? void 0 : mutationOptions.headers),
298
527
  flagsToHeaders(mutationOptions != null ? mutationOptions : {})
@@ -313,11 +542,7 @@ function createPyloHooks(options) {
313
542
  },
314
543
  onSuccess: (data, variables, onMutateResult, context) => {
315
544
  var _a2;
316
- if (!isDryRun) {
317
- void queryClient.invalidateQueries({
318
- queryKey: ["pylo", entity]
319
- });
320
- }
545
+ if (!isDryRun) void invalidateEntity(queryClient, realtime, entity);
321
546
  (_a2 = mutationOptions == null ? void 0 : mutationOptions.onSuccess) == null ? void 0 : _a2.call(mutationOptions, data, variables, onMutateResult, context);
322
547
  }
323
548
  });
@@ -403,7 +628,7 @@ function createPyloHooks(options) {
403
628
  optionsRef.current = hookOptions;
404
629
  const startUpload = useCallback(
405
630
  async (files, overrides) => {
406
- var _a2, _b, _c, _d;
631
+ var _a2, _b2, _c, _d;
407
632
  const opts = { ...optionsRef.current, ...overrides };
408
633
  const merged = mergeHeaders(globalHeaders, opts.headers);
409
634
  const list = Array.isArray(files) ? files : [files];
@@ -419,13 +644,13 @@ function createPyloHooks(options) {
419
644
  const loadedPerFile = new Array(list.length).fill(0);
420
645
  const doneFlags = new Array(list.length).fill(false);
421
646
  const reportProgress = () => {
422
- var _a3, _b2;
647
+ var _a3, _b3;
423
648
  const loaded = loadedPerFile.reduce((sum, bytes) => sum + bytes, 0);
424
649
  const percent = grandTotal !== void 0 ? grandTotal > 0 ? Math.round(loaded / grandTotal * 100) : 0 : Math.round(
425
650
  doneFlags.filter(Boolean).length / doneFlags.length * 100
426
651
  );
427
652
  setProgress(percent);
428
- (_b2 = (_a3 = optionsRef.current) == null ? void 0 : _a3.onProgress) == null ? void 0 : _b2.call(_a3, {
653
+ (_b3 = (_a3 = optionsRef.current) == null ? void 0 : _a3.onProgress) == null ? void 0 : _b3.call(_a3, {
429
654
  loaded,
430
655
  total: grandTotal != null ? grandTotal : loaded,
431
656
  percent
@@ -438,7 +663,7 @@ function createPyloHooks(options) {
438
663
  const input = buildCreateUploadInput(opts, list.length);
439
664
  const results = await Promise.all(
440
665
  list.map(async (_source, index) => {
441
- var _a3, _b2;
666
+ var _a3, _b3;
442
667
  const part = parts[index];
443
668
  const data = await transport(
444
669
  CREATE_UPLOAD_MUTATION,
@@ -460,7 +685,7 @@ function createPyloHooks(options) {
460
685
  id: uploadUrl.id,
461
686
  fileName: part.fileName,
462
687
  mimeType: part.mimeType,
463
- size: (_b2 = part.size) != null ? _b2 : 0
688
+ size: (_b3 = part.size) != null ? _b3 : 0
464
689
  };
465
690
  })
466
691
  );
@@ -476,7 +701,7 @@ function createPyloHooks(options) {
476
701
  void queryClient.invalidateQueries({ queryKey: ["pylo", "pyloMedia"] });
477
702
  setUploadedFiles(results);
478
703
  setProgress(100);
479
- (_b = (_a2 = optionsRef.current) == null ? void 0 : _a2.onSuccess) == null ? void 0 : _b.call(_a2, results);
704
+ (_b2 = (_a2 = optionsRef.current) == null ? void 0 : _a2.onSuccess) == null ? void 0 : _b2.call(_a2, results);
480
705
  return results;
481
706
  } catch (thrown) {
482
707
  controller.abort();
@@ -521,6 +746,37 @@ function createPyloHooks(options) {
521
746
  staleTime: (_a2 = queryOptions == null ? void 0 : queryOptions.staleTimeMs) != null ? _a2 : 4 * 60 * 1e3
522
747
  });
523
748
  }
749
+ function usePyloSubscription(entity, subscribeOptions, onEvent, handlers) {
750
+ const realtime = useRealtime();
751
+ const latest = useRef({ subscribeOptions, onEvent, handlers });
752
+ latest.current = { subscribeOptions, onEvent, handlers };
753
+ const enabled = realtime !== null && subscribeOptions.enabled !== false;
754
+ const key = hashKey([
755
+ subscribeOptions.select,
756
+ subscribeOptions.filter,
757
+ subscribeOptions.on
758
+ ]);
759
+ useEffect(() => {
760
+ if (!enabled) return;
761
+ const { select, filter, on } = latest.current.subscribeOptions;
762
+ const sub = realtime.client.subscribe(
763
+ entity,
764
+ { select, filter, on },
765
+ {
766
+ onEvent: (event) => latest.current.onEvent(event),
767
+ onResync: () => {
768
+ var _a2, _b2;
769
+ return (_b2 = (_a2 = latest.current.handlers) == null ? void 0 : _a2.onResync) == null ? void 0 : _b2.call(_a2);
770
+ },
771
+ onError: (error) => {
772
+ var _a2, _b2;
773
+ return (_b2 = (_a2 = latest.current.handlers) == null ? void 0 : _a2.onError) == null ? void 0 : _b2.call(_a2, error);
774
+ }
775
+ }
776
+ );
777
+ return () => sub.unsubscribe();
778
+ }, [enabled, realtime, entity, key]);
779
+ }
524
780
  return {
525
781
  usePyloList,
526
782
  usePyloInfiniteList,
@@ -536,7 +792,8 @@ function createPyloHooks(options) {
536
792
  usePyloEventPropertyKeys,
537
793
  usePyloEventFieldValues,
538
794
  usePyloUpload,
539
- usePyloFileUrl
795
+ usePyloFileUrl,
796
+ usePyloSubscription
540
797
  };
541
798
  }
542
799
 
@@ -8,7 +8,7 @@ import {
8
8
  useSyncExternalStore
9
9
  } from "react";
10
10
  import { useQuery, useQueryClient } from "@tanstack/react-query";
11
- import { createPyloClient, resolveEndpoint } from "@pylo/core";
11
+ import { createPyloClient, createRealtime, resolveEndpoint } from "@pylo/core";
12
12
  import { ME_QUERY } from "@pylo/auth";
13
13
 
14
14
  // src/session/store.ts
@@ -378,7 +378,7 @@ var LOGGED_OUT = { status: "loggedOut", token: null };
378
378
  var NO_OP = () => () => {
379
379
  };
380
380
  function PyloProvider(props) {
381
- var _a, _b;
381
+ var _a, _b, _c;
382
382
  const queryClient = useQueryClient();
383
383
  const endpoint = resolveEndpoint(props.endpoint);
384
384
  const storeRef = useRef(null);
@@ -421,6 +421,20 @@ function PyloProvider(props) {
421
421
  const getTokenRef = useRef(getTokenProp);
422
422
  getTokenRef.current = getTokenProp;
423
423
  const isEscapeHatch = getTokenProp !== void 0;
424
+ const transportRef = useRef(null);
425
+ const realtimeRef = useRef(null);
426
+ (_c = realtimeRef.current) != null ? _c : realtimeRef.current = {
427
+ client: createRealtime({
428
+ request: (query, variables) => transportRef.current(query, variables),
429
+ auth: async () => {
430
+ const token = isEscapeHatch ? await getTokenRef.current() : await store.getToken();
431
+ return { token };
432
+ }
433
+ }),
434
+ liveQueries: /* @__PURE__ */ new Map()
435
+ };
436
+ const realtime = realtimeRef.current;
437
+ useEffect(() => () => realtime.client.close(), [realtime]);
424
438
  const value = useMemo(() => {
425
439
  const getToken = isEscapeHatch ? () => getTokenRef.current() : () => store.getToken();
426
440
  const transport = createDirectTransport({
@@ -428,13 +442,17 @@ function PyloProvider(props) {
428
442
  getToken,
429
443
  ...store ? { onUnauthorized: () => store.refresh() } : {}
430
444
  });
431
- return { transport, endpoint, state, store, social, getToken };
432
- }, [endpoint, state, store, social, isEscapeHatch]);
445
+ transportRef.current = transport;
446
+ return { transport, endpoint, state, store, social, getToken, realtime };
447
+ }, [endpoint, state, store, social, isEscapeHatch, realtime]);
433
448
  return /* @__PURE__ */ jsx(PyloContext.Provider, { value, children: props.children });
434
449
  }
435
450
  function usePyloTransport() {
436
451
  return usePyloContext().transport;
437
452
  }
453
+ function usePyloRealtime() {
454
+ return usePyloContext().realtime;
455
+ }
438
456
  function usePyloClient() {
439
457
  const { endpoint, getToken } = usePyloContext();
440
458
  return useMemo(
@@ -495,6 +513,7 @@ export {
495
513
  createDirectTransport,
496
514
  PyloProvider,
497
515
  usePyloTransport,
516
+ usePyloRealtime,
498
517
  usePyloClient,
499
518
  usePyloAuth
500
519
  };
package/dist/hooks.d.ts CHANGED
@@ -1,12 +1,25 @@
1
- import { T as Transport } from './transport-55cSrvQo.js';
1
+ import { T as Transport, L as LiveRealtime } from './provider-DF_-sV-r.js';
2
2
  import { UseQueryResult, UseInfiniteQueryResult, UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
3
- import { EntityNameWith, ListSelect, FilterInput, RequestOptions, WithPagination, EntityRelationPath, UploadAttachTarget, UploadProgress, PyloUploadedFile, UploadSource, ListSelectConstraint, ListOptions, EntityResult, PaginationData, SelectConstraint, ByIdOptions, EntityName, AggregateMetricInput, AggregateGroupByInput, AggregateOptions, AggregateResult, EventMetricInput, EventGroupByInput, EventAggregateOptions, UpsertInput, MutationRequestOptions, PyloEvent, PyloEventInput, PaginationSelect, EventListOptions, PyloEventListResult, PyloEventPropertyKeysOptions, PyloEventProperty, PyloEventFieldValuesOptions, PyloEventFieldValue } from '@pylo/core';
3
+ import { EntityNameWith, ListSelect, FilterInput, RequestOptions, WithPagination, EntityRelationPath, UploadAttachTarget, UploadProgress, PyloUploadedFile, UploadSource, ListSelectConstraint, ListOptions, EntityResult, PaginationData, SelectConstraint, ByIdOptions, EntityName, AggregateMetricInput, AggregateGroupByInput, AggregateOptions, AggregateResult, EventMetricInput, EventGroupByInput, EventAggregateOptions, UpsertInput, MutationRequestOptions, PyloEvent, PyloEventInput, PaginationSelect, EventListOptions, PyloEventListResult, PyloEventPropertyKeysOptions, PyloEventProperty, PyloEventFieldValuesOptions, PyloEventFieldValue, SubscribeOptions, RealtimeHandlers } from '@pylo/core';
4
+ import 'react/jsx-runtime';
5
+ import 'react';
6
+ import '@pylo/auth';
4
7
 
5
8
  interface HooksOptions {
6
9
  /** Defaults to reading the transport `PyloProvider` supplies. */
7
10
  useTransport?: () => Transport;
11
+ /** Defaults to the connection `PyloProvider` supplies; `null` turns `live` off. */
12
+ useRealtime?: () => LiveRealtime | null;
8
13
  headers?: Record<string, string>;
9
14
  }
15
+ interface LiveOption {
16
+ /**
17
+ * Keep the result current over the realtime connection: updates and removals
18
+ * are patched into the cache, anything the cache can't place correctly
19
+ * triggers a background refetch.
20
+ */
21
+ live?: boolean;
22
+ }
10
23
  type ListHookResult<T, Sel = unknown> = {
11
24
  data: T[] | undefined;
12
25
  isLoading: boolean;
@@ -21,7 +34,7 @@ type InfiniteListOptions<S, E extends EntityNameWith<S, "list">, Sel extends Lis
21
34
  perPage?: number;
22
35
  select: Sel;
23
36
  filter?: FilterInput;
24
- } & RequestOptions;
37
+ } & RequestOptions & LiveOption;
25
38
  type InfinitePagination<Sel> = Pick<PaginationData, "current_page" | "has_more_pages" | (Sel extends {
26
39
  _pagination: infer PS;
27
40
  } ? keyof PS & keyof PaginationData : never)>;
@@ -63,7 +76,7 @@ interface UploadHookResult<S> {
63
76
  reset: () => void;
64
77
  }
65
78
  declare function createPyloHooks<S>(options?: HooksOptions): {
66
- usePyloList: <E extends EntityNameWith<S, "list">, const Sel extends ListSelectConstraint<S, E, Sel>>(entity: E, queryOptions: ListOptions<S, E, Sel> & RequestOptions) => ListHookResult<EntityResult<S, E, Sel>, Sel>;
79
+ usePyloList: <E extends EntityNameWith<S, "list">, const Sel extends ListSelectConstraint<S, E, Sel>>(entity: E, queryOptions: ListOptions<S, E, Sel> & RequestOptions & LiveOption) => ListHookResult<EntityResult<S, E, Sel>, Sel>;
67
80
  usePyloInfiniteList: <E extends EntityNameWith<S, "list">, const Sel extends ListSelectConstraint<S, E, Sel>>(entity: E, infiniteOptions: InfiniteListOptions<S, E, Sel>) => UseInfiniteQueryResult<{
68
81
  data: Array<EntityResult<S, E, Sel>>;
69
82
  pages: Array<{
@@ -71,7 +84,7 @@ declare function createPyloHooks<S>(options?: HooksOptions): {
71
84
  pagination: InfinitePagination<Sel>;
72
85
  }>;
73
86
  }>;
74
- usePyloById: <E extends EntityNameWith<S, "byId">, const Sel extends SelectConstraint<S, E, Sel>>(entity: E, id: string | null | undefined, queryOptions: ByIdOptions<S, E, Sel> & RequestOptions) => UseQueryResult<EntityResult<S, E, Sel> | null>;
87
+ usePyloById: <E extends EntityNameWith<S, "byId">, const Sel extends SelectConstraint<S, E, Sel>>(entity: E, id: string | null | undefined, queryOptions: ByIdOptions<S, E, Sel> & RequestOptions & LiveOption) => UseQueryResult<EntityResult<S, E, Sel> | null>;
75
88
  usePyloMe: <const Sel extends SelectConstraint<S, "me" & EntityName<S>, Sel>>(queryOptions: ByIdOptions<S, "me" & EntityName<S>, Sel> & RequestOptions) => UseQueryResult<EntityResult<S, "me" & EntityName<S>, Sel>>;
76
89
  usePyloAggregate: <E extends EntityName<S>, const M extends Record<string, AggregateMetricInput<S, E>>, const G extends readonly AggregateGroupByInput<S, E>[] = []>(entity: E, queryOptions: AggregateOptions<S, E, M, G> & RequestOptions) => UseQueryResult<AggregateResult<M, G>>;
77
90
  usePyloEventAggregate: <const M extends Record<string, EventMetricInput>, const G extends readonly EventGroupByInput[] = []>(queryOptions: EventAggregateOptions<M, G> & RequestOptions) => UseQueryResult<AggregateResult<M, G>>;
@@ -98,6 +111,9 @@ declare function createPyloHooks<S>(options?: HooksOptions): {
98
111
  usePyloFileUrl: (id: string | null | undefined, queryOptions?: {
99
112
  staleTimeMs?: number;
100
113
  } & RequestOptions) => UseQueryResult<string>;
114
+ usePyloSubscription: <E extends EntityNameWith<S, "list">, const Sel extends SelectConstraint<S, E, Sel>>(entity: E, subscribeOptions: SubscribeOptions<S, E, Sel> & {
115
+ enabled?: boolean;
116
+ }, onEvent: RealtimeHandlers<EntityResult<S, E, Sel>>["onEvent"], handlers?: Omit<RealtimeHandlers<EntityResult<S, E, Sel>>, "onEvent">) => void;
101
117
  };
102
118
 
103
- export { type InfiniteListOptions, type ListHookResult, type StartUploadOptions, type UploadHookOptions, type UploadHookResult, createPyloHooks };
119
+ export { type InfiniteListOptions, type ListHookResult, type LiveOption, type StartUploadOptions, type UploadHookOptions, type UploadHookResult, createPyloHooks };
package/dist/hooks.js CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import {
3
3
  createPyloHooks
4
- } from "./chunk-FKUNSR3E.js";
5
- import "./chunk-BPRHGSZF.js";
4
+ } from "./chunk-C3KAJKBM.js";
5
+ import "./chunk-OPMVPQER.js";
6
6
  export {
7
7
  createPyloHooks
8
8
  };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { EntityName, EntitySelect, EntityResult } from '@pylo/core';
2
- export { AggregateFilterInput, AggregateFunction, AggregateGroupByInput, AggregateInput, AggregateInterval, AggregateIntervalUnit, AggregateMetricInput, AggregateOptions, AggregateResult, AggregateRow, AggregateRows, AggregateSortInput, AggregateSortKey, AggregateTotal, ByIdOptions, CallableEntityName, CreateUploadInput, CreateUploadUrlOptions, DimensionInput, DimensionValue, EntityAggregateApi, EntityCapability, EntityFields, EntityMethodName, EntityName, EntityNameWith, EntityRelationPath, EntityRelations, EntityResult, EntitySelect, EventAggregateOptions, EventGroupByInput, EventListFilterInput, EventListOptions, EventMetricInput, FieldName, FilesClient, FilterInput, HasCapability, IngestEvents, ListOptions, ListResult, Me, MetricValue, MutationRequestOptions, NumericFieldName, PaginationData, PaginationInput, PyloError, PyloEvent, PyloEventFieldValue, PyloEventFieldValuesOptions, PyloEventInput, PyloEventListResult, PyloEventProperty, PyloEventPropertyKeysOptions, PyloUploadedFile, QueryInput, QueryInputCondition, QueryOperator, RequestOptions, RowKeyOf, SearchValueInput, SortInput, SortOrder, SystemEntityName, TimeBucketInput, UploadAttachTarget, UploadOptions, UploadProgress, UploadSource, UploadUrl, UpsertInput, VirtualEntityName } from '@pylo/core';
2
+ export { AggregateFilterInput, AggregateFunction, AggregateGroupByInput, AggregateInput, AggregateInterval, AggregateIntervalUnit, AggregateMetricInput, AggregateOptions, AggregateResult, AggregateRow, AggregateRows, AggregateSortInput, AggregateSortKey, AggregateTotal, ByIdOptions, CallableEntityName, CreateUploadInput, CreateUploadUrlOptions, DimensionInput, DimensionValue, EntityAggregateApi, EntityCapability, EntityFields, EntityMethodName, EntityName, EntityNameWith, EntityRelationPath, EntityRelations, EntityResult, EntitySelect, EventAggregateOptions, EventGroupByInput, EventListFilterInput, EventListOptions, EventMetricInput, FieldName, FilesClient, FilterInput, HasCapability, IngestEvents, ListOptions, ListResult, Me, MetricValue, MutationRequestOptions, NumericFieldName, PaginationData, PaginationInput, PyloError, PyloEvent, PyloEventFieldValue, PyloEventFieldValuesOptions, PyloEventInput, PyloEventListResult, PyloEventProperty, PyloEventPropertyKeysOptions, PyloUploadedFile, QueryInput, QueryInputCondition, QueryOperator, RealtimeClient, RealtimeEvent, RealtimeEventKind, RealtimeHandlers, RealtimeStatus, RealtimeSubscription, RequestOptions, RowKeyOf, SearchValueInput, SortInput, SortOrder, SubscribeOptions, SystemEntityName, TimeBucketInput, UploadAttachTarget, UploadOptions, UploadProgress, UploadSource, UploadUrl, UpsertInput, VirtualEntityName } from '@pylo/core';
3
3
  import { AuthResult, SocialProvider } from '@pylo/auth';
4
4
  export { AuthResult, HandoffCrypto, PyloUser, SocialProvider } from '@pylo/auth';
5
- import { P as PyloStorage, a as PyloAuth } from './provider-4DBl_6p0.js';
6
- export { O as OpenAuthSession, b as PyloProvider, c as PyloProviderProps, S as SocialLogin, d as SocialLoginResult, e as createLocalStorageAdapter, f as createMemoryStorage, u as usePyloClient, g as usePyloTransport } from './provider-4DBl_6p0.js';
7
- export { D as DirectTransportOptions, T as Transport, c as createDirectTransport } from './transport-55cSrvQo.js';
8
- export { InfiniteListOptions, ListHookResult, StartUploadOptions, UploadHookOptions, UploadHookResult, createPyloHooks } from './hooks.js';
5
+ import { P as PyloStorage, a as PyloAuth } from './provider-DF_-sV-r.js';
6
+ export { D as DirectTransportOptions, L as LiveRealtime, O as OpenAuthSession, b as PyloProvider, c as PyloProviderProps, S as SocialLogin, d as SocialLoginResult, T as Transport, e as createDirectTransport, f as createLocalStorageAdapter, g as createMemoryStorage, u as usePyloClient, h as usePyloRealtime, i as usePyloTransport } from './provider-DF_-sV-r.js';
7
+ export { InfiniteListOptions, ListHookResult, LiveOption, StartUploadOptions, UploadHookOptions, UploadHookResult, createPyloHooks } from './hooks.js';
9
8
  import 'react/jsx-runtime';
10
9
  import 'react';
11
10
  import '@tanstack/react-query';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createPyloHooks
3
- } from "./chunk-FKUNSR3E.js";
3
+ } from "./chunk-C3KAJKBM.js";
4
4
  import {
5
5
  PyloProvider,
6
6
  createDirectTransport,
@@ -9,8 +9,9 @@ import {
9
9
  createSessionStore,
10
10
  usePyloAuth,
11
11
  usePyloClient,
12
+ usePyloRealtime,
12
13
  usePyloTransport
13
- } from "./chunk-BPRHGSZF.js";
14
+ } from "./chunk-OPMVPQER.js";
14
15
 
15
16
  // src/index.ts
16
17
  import { PyloError } from "@pylo/core";
@@ -25,5 +26,6 @@ export {
25
26
  createSessionStore,
26
27
  usePyloAuth2 as usePyloAuth,
27
28
  usePyloClient,
29
+ usePyloRealtime,
28
30
  usePyloTransport
29
31
  };
@@ -1,8 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
- import { PyloClient } from '@pylo/core';
3
+ import { RealtimeClient, PyloClient } from '@pylo/core';
4
4
  import { SocialProvider, AuthResult, PyloUser, HandoffCrypto } from '@pylo/auth';
5
- import { T as Transport } from './transport-55cSrvQo.js';
6
5
 
7
6
  /**
8
7
  * The three operations a session needs from a key-value store. Async because
@@ -40,6 +39,23 @@ interface SocialLogin<P extends string = SocialProvider, U extends string = stri
40
39
  completeLogin(url?: string): Promise<SocialLoginResult>;
41
40
  }
42
41
 
42
+ /**
43
+ * How the hooks reach the API. `@pylo/react` talks to it directly; `@pylo/nextjs`
44
+ * posts to its route handler so the token can stay in an httpOnly cookie.
45
+ */
46
+ type Transport = (query: string, variables: Record<string, unknown>, headers?: Record<string, string>) => Promise<unknown>;
47
+ interface DirectTransportOptions {
48
+ endpoint: string;
49
+ getToken: () => Promise<string | null>;
50
+ /** Called once on an unauthorized response; returns the token to retry with. */
51
+ onUnauthorized?: () => Promise<string | null>;
52
+ }
53
+ declare function createDirectTransport(options: DirectTransportOptions): Transport;
54
+
55
+ interface LiveRealtime {
56
+ client: RealtimeClient;
57
+ liveQueries: Map<string, number>;
58
+ }
43
59
  interface PyloProviderProps {
44
60
  children: ReactNode;
45
61
  endpoint?: string;
@@ -60,6 +76,7 @@ interface PyloProviderProps {
60
76
  }
61
77
  declare function PyloProvider(props: PyloProviderProps): react_jsx_runtime.JSX.Element;
62
78
  declare function usePyloTransport(): Transport;
79
+ declare function usePyloRealtime(): LiveRealtime;
63
80
  declare function usePyloClient<S>(): PyloClient<S>;
64
81
  interface PyloAuth<P extends string = SocialProvider, U extends string = string> extends SocialLogin<P, U> {
65
82
  isLoading: boolean;
@@ -71,4 +88,4 @@ interface PyloAuth<P extends string = SocialProvider, U extends string = string>
71
88
  }
72
89
  declare function usePyloAuth(): PyloAuth;
73
90
 
74
- export { type OpenAuthSession as O, type PyloStorage as P, type SocialLogin as S, type PyloAuth as a, PyloProvider as b, type PyloProviderProps as c, type SocialLoginResult as d, createLocalStorageAdapter as e, createMemoryStorage as f, usePyloTransport as g, usePyloAuth as h, usePyloClient as u };
91
+ export { type DirectTransportOptions as D, type LiveRealtime as L, type OpenAuthSession as O, type PyloStorage as P, type SocialLogin as S, type Transport as T, type PyloAuth as a, PyloProvider as b, type PyloProviderProps as c, type SocialLoginResult as d, createDirectTransport as e, createLocalStorageAdapter as f, createMemoryStorage as g, usePyloRealtime as h, usePyloTransport as i, usePyloAuth as j, usePyloClient as u };
@@ -2,5 +2,4 @@ import 'react/jsx-runtime';
2
2
  import 'react';
3
3
  import '@pylo/core';
4
4
  import '@pylo/auth';
5
- export { a as PyloAuth, b as PyloProvider, c as PyloProviderProps, h as usePyloAuth, u as usePyloClient, g as usePyloTransport } from './provider-4DBl_6p0.js';
6
- import './transport-55cSrvQo.js';
5
+ export { L as LiveRealtime, a as PyloAuth, b as PyloProvider, c as PyloProviderProps, j as usePyloAuth, u as usePyloClient, h as usePyloRealtime, i as usePyloTransport } from './provider-DF_-sV-r.js';
package/dist/provider.js CHANGED
@@ -2,11 +2,13 @@ import {
2
2
  PyloProvider,
3
3
  usePyloAuth,
4
4
  usePyloClient,
5
+ usePyloRealtime,
5
6
  usePyloTransport
6
- } from "./chunk-BPRHGSZF.js";
7
+ } from "./chunk-OPMVPQER.js";
7
8
  export {
8
9
  PyloProvider,
9
10
  usePyloAuth,
10
11
  usePyloClient,
12
+ usePyloRealtime,
11
13
  usePyloTransport
12
14
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylo/react",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Type-safe React SDK for Pylo — hooks, session provider, client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@pylo/auth": "0.2.0",
40
- "@pylo/core": "0.10.0"
40
+ "@pylo/core": "0.12.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@tanstack/react-query": ">=5.0.0",
@@ -45,9 +45,13 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@tanstack/react-query": "^5.0.0",
48
+ "@testing-library/react": "^16.3.3",
48
49
  "@types/node": "^22.15.21",
49
50
  "@types/react": "^19.0.0",
51
+ "@types/react-dom": "^19.3.0",
52
+ "happy-dom": "^21.0.3",
50
53
  "react": "^19.0.0",
54
+ "react-dom": "^19.2.4",
51
55
  "tsup": "^8.5.1",
52
56
  "typescript": "^5.9.3"
53
57
  },
@@ -1,14 +0,0 @@
1
- /**
2
- * How the hooks reach the API. `@pylo/react` talks to it directly; `@pylo/nextjs`
3
- * posts to its route handler so the token can stay in an httpOnly cookie.
4
- */
5
- type Transport = (query: string, variables: Record<string, unknown>, headers?: Record<string, string>) => Promise<unknown>;
6
- interface DirectTransportOptions {
7
- endpoint: string;
8
- getToken: () => Promise<string | null>;
9
- /** Called once on an unauthorized response; returns the token to retry with. */
10
- onUnauthorized?: () => Promise<string | null>;
11
- }
12
- declare function createDirectTransport(options: DirectTransportOptions): Transport;
13
-
14
- export { type DirectTransportOptions as D, type Transport as T, createDirectTransport as c };