@marktiderman/genesis-core 1.5.1 → 1.7.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.
Files changed (55) hide show
  1. package/README.md +112 -0
  2. package/dist/chunk-ADD2RIGA.js +108 -0
  3. package/dist/chunk-ADD2RIGA.js.map +1 -0
  4. package/dist/chunk-KXVHDK3Q.js +71 -0
  5. package/dist/chunk-KXVHDK3Q.js.map +1 -0
  6. package/dist/{chunk-F3BE7NGM.js → chunk-OBUBP4BY.js} +83 -47
  7. package/dist/chunk-OBUBP4BY.js.map +1 -0
  8. package/dist/{chunk-6Q477CHB.js → chunk-QFR6YJ3R.js} +7 -4
  9. package/dist/chunk-QFR6YJ3R.js.map +1 -0
  10. package/dist/chunk-V3GDAELU.js +3 -0
  11. package/dist/{chunk-HT43MPKW.js.map → chunk-V3GDAELU.js.map} +1 -1
  12. package/dist/{chunk-3JSFTLDV.js → chunk-Y3SCYMNN.js} +6 -6
  13. package/dist/{chunk-3JSFTLDV.js.map → chunk-Y3SCYMNN.js.map} +1 -1
  14. package/dist/{chunk-E5VYFAQO.js → chunk-Y475Z7BM.js} +202 -11
  15. package/dist/chunk-Y475Z7BM.js.map +1 -0
  16. package/dist/{chunk-ZK5C3OOT.js → chunk-ZKMAU6NN.js} +12 -3
  17. package/dist/chunk-ZKMAU6NN.js.map +1 -0
  18. package/dist/hooks/index.d.ts +4 -2
  19. package/dist/hooks/index.js +9 -7
  20. package/dist/hooks/mutation-mode.d.ts +2 -2
  21. package/dist/hooks/mutation-mode.js +1 -1
  22. package/dist/hooks/related-keys.d.ts +2 -2
  23. package/dist/hooks/use-capabilities.d.ts +1 -1
  24. package/dist/hooks/use-infinite-resource.d.ts +50 -0
  25. package/dist/hooks/use-infinite-resource.js +6 -0
  26. package/dist/hooks/use-infinite-resource.js.map +1 -0
  27. package/dist/hooks/use-live.d.ts +27 -0
  28. package/dist/hooks/use-live.js +5 -0
  29. package/dist/hooks/use-live.js.map +1 -0
  30. package/dist/hooks/use-one.d.ts +9 -2
  31. package/dist/hooks/use-one.js +2 -1
  32. package/dist/hooks/use-resource-form.d.ts +2 -2
  33. package/dist/hooks/use-resource-form.js +6 -5
  34. package/dist/hooks/use-resource.d.ts +14 -2
  35. package/dist/hooks/use-resource.js +4 -3
  36. package/dist/index.d.ts +6 -4
  37. package/dist/index.js +10 -8
  38. package/dist/navigation.d.ts +108 -0
  39. package/dist/navigation.js +101 -0
  40. package/dist/navigation.js.map +1 -0
  41. package/dist/provider/index.d.ts +54 -6
  42. package/dist/provider/index.js +1 -1
  43. package/dist/{read-outcome-kgNhfuST.d.ts → read-outcome-C_9HbXvb.d.ts} +1 -1
  44. package/dist/schema/index.d.ts +4 -4
  45. package/dist/slots.d.ts +60 -0
  46. package/dist/slots.js +12 -0
  47. package/dist/slots.js.map +1 -0
  48. package/dist/{types-5cuflvha.d.ts → types-C7FJtFXV.d.ts} +12 -0
  49. package/dist/{types-DDOmG9hj.d.ts → types-CS9UpxQS.d.ts} +62 -4
  50. package/package.json +9 -1
  51. package/dist/chunk-6Q477CHB.js.map +0 -1
  52. package/dist/chunk-E5VYFAQO.js.map +0 -1
  53. package/dist/chunk-F3BE7NGM.js.map +0 -1
  54. package/dist/chunk-HT43MPKW.js +0 -3
  55. package/dist/chunk-ZK5C3OOT.js.map +0 -1
package/README.md CHANGED
@@ -101,6 +101,7 @@ const provider = createSupabaseProvider(supabase, {
101
101
  | `toFormValues(row)` | Row → edit-form values, for a form shaped differently from its table. Served as `DataProvider.toFormValues`, which `useResourceForm` applies in edit mode. The reverse is `prepare`. |
102
102
  | `invalidates` | Other query keys a write makes stale, as a list or `(event) => keys`. Served as `DataProvider.invalidates`, which `useResource` and `useResourceForm` (and so `ResourcePage` and `ResourceForm`) invalidate after their writes, on top of `[resource]`. |
103
103
  | `onWrite(event)` | Called after every committed create, update and delete, including writes made straight through the provider. Awaited; if it throws, the error is logged and the write is still reported saved. |
104
+ | `realtime` / `resources[r].realtime` | Serve `subscribe` through Supabase Realtime, for `live` lists and records. Off by default: `true`, a list of resources, or `(resource) => boolean`; a resource's own `realtime` wins. See [Live updates](#live-updates-realtime). |
104
105
 
105
106
  Both hooks also take an `invalidates` option for keys one screen cares about:
106
107
  `useResource("tasks", { invalidates: [["me"]] })`.
@@ -232,6 +233,117 @@ const tasks = useResource("tasks", {
232
233
  await tasks.remove(id); // resolves at once; sent in 5 s unless undone
233
234
  ```
234
235
 
236
+ ## Cursor paging
237
+
238
+ For long lists that grow with "Load more", page by cursor (keyset) instead of
239
+ offset: pages stay right while rows are added or removed, and a deep page
240
+ costs no more than the first.
241
+
242
+ ```ts
243
+ const tasks = useInfiniteResource("tasks", {
244
+ perPage: 50,
245
+ defaultSort: { field: "due_at", order: "asc" },
246
+ });
247
+ tasks.rows; // every loaded row
248
+ tasks.total; // from the first page's count
249
+ tasks.hasNextPage && tasks.fetchNextPage();
250
+ ```
251
+
252
+ - **The contract.** `getList` with `pagination: { page, perPage, cursor }`,
253
+ where `cursor` is `null` for the first page and the previous page's
254
+ `nextCursor` after that. A provider that serves it
255
+ (`capabilities().cursorPagination`) returns `nextCursor` (`null` on the
256
+ last page) and may return `total: -1` after the first page (not counted).
257
+ A provider that doesn't ignores `cursor` and pages by `page`;
258
+ `useInfiniteResource` then pages by offset until `total` rows are loaded.
259
+ - **Supabase.**
260
+ - It orders by the sort column and then the `orderKey` (default `id`) and
261
+ fetches one row more than the page to tell whether there is a next
262
+ page. A page is at most `maxPerPage - 1` rows, so that extra row fits
263
+ under PostgREST's `max-rows`.
264
+ - The cursor (opaque, base64 JSON) holds the last row's sort and key
265
+ values and the sort it was made for. A cursor from another sort is
266
+ refused.
267
+ - The keyset filter handles ascending and descending sorts, ties (broken
268
+ by the key) and null sort values, matching Postgres's default null
269
+ placement (last when ascending, first when descending, pinned in the
270
+ query).
271
+ - It refuses, with an `invalid` `DataReadError` whose `cause` says why:
272
+ `orderKey: false`, a sort on an embedded field (`author.name`), a sort
273
+ column with non-scalar values, and a `select` that leaves out the sort
274
+ or key column.
275
+ - Only the first page is counted.
276
+ - **Writes and pending patches.** Pages live under
277
+ `[resource, "infinite", params]`, so writes (which invalidate `[resource]`)
278
+ refetch them. Optimistic and undoable patches are applied to every loaded
279
+ page and survive refetches, like lists.
280
+ - **A row shows once.** A row that moved into a later page while the user
281
+ scrolled (its sort value changed, or rows were added before it) is shown
282
+ where it first appeared, not twice.
283
+
284
+ ## Live updates (realtime)
285
+
286
+ `useResource(resource, { live: true })`, `useOne(resource, id, { live: true })`
287
+ and genesis-ui's `<ResourcePage live />` keep what is on screen current while
288
+ they are mounted. They subscribe through the provider's optional
289
+ `subscribe(resource, params, callback)` and, when it reports a change,
290
+ refetch the resource's cached reads (`useResource`: every `[resource, …]`
291
+ query; `useOne`: that record, and only for its own id).
292
+
293
+ - **Events are hints, never data.** The callback gets `{ type: "created" |
294
+ "updated" | "deleted", id? }`, or `{ type: "changed" }` when changes may have
295
+ been missed (after a reconnect). Nothing from an event is written to the
296
+ cache: rows always come from the provider's normal read, under its RLS.
297
+ - **One refetch per burst.** Events within `debounceMs` (default 250) of each
298
+ other cause one refetch: `live: { debounceMs: 1000 }`. A steady stream
299
+ still refetches at least every `4 × debounceMs`.
300
+ - **Pending writes stay applied.** A refetch while an optimistic or undoable
301
+ write is pending gets that write's patch re-applied (see
302
+ [Mutation modes](#mutation-modes)), so a row deleted with Undo stays hidden
303
+ and an edit stays shown. Your own committed writes come back as events too;
304
+ the extra refetch is harmless and is not filtered out.
305
+ - **A hidden tab waits.** While the page is hidden, reads are only marked
306
+ stale; they refetch when it is shown again (with TanStack Query's default
307
+ `refetchOnWindowFocus`) or when next used.
308
+ - **Without `subscribe`**, or for a resource the provider doesn't watch,
309
+ `live` does nothing. `capabilities(resource).realtime` says whether it
310
+ will work.
311
+
312
+ ### Supabase setup
313
+
314
+ ```ts
315
+ const provider = createSupabaseProvider(supabase, {
316
+ resources: { tasks: { realtime: true } }, // or realtime: true | ["tasks"] for several
317
+ });
318
+ ```
319
+
320
+ ```sql
321
+ -- 1. Publish the table's changes to Realtime.
322
+ alter publication supabase_realtime add table public.tasks;
323
+ -- 2. Keep RLS on: Realtime only sends an insert or update to a subscriber
324
+ -- whose SELECT policies allow the new row.
325
+ alter table public.tasks enable row level security;
326
+ ```
327
+
328
+ - The provider opens one `postgres_changes` channel per subscription and
329
+ removes it on unsubscribe. `useOne`'s channel is not filtered on the
330
+ server (Realtime never delivers deletes through a filter); its id is
331
+ matched on arrival. A
332
+ resource named `schema.table` listens in that schema; otherwise `public`.
333
+ - **What leaks, and what doesn't.** Insert and update events are checked
334
+ against RLS by Realtime. **Delete events are not:** every subscriber to the
335
+ table receives the deleted row's primary key (with RLS on, Supabase sends
336
+ only the key, even under `REPLICA IDENTITY FULL`). The provider passes on only the id, and the hooks only refetch,
337
+ so a user never sees a row their read doesn't return. Don't enable
338
+ realtime on a table whose ids are themselves sensitive.
339
+ - **Cost.** Each mounted live list or record is one Realtime channel, and
340
+ each change refetches every live list of that table for every subscriber
341
+ (debounced per client). Realtime checks RLS once per subscriber per
342
+ change, so busy tables with many viewers multiply database load. Enable it
343
+ where people watch shared data change, not on every table.
344
+ - `capabilities(resource).realtime` is true only for resources with realtime
345
+ on and a client with `channel()` (any `@supabase/supabase-js` client).
346
+
235
347
  ## Unsaved changes
236
348
 
237
349
  `useResourceForm({ warnWhenUnsaved: true })` (default off) warns while the
@@ -0,0 +1,108 @@
1
+ import { useLiveInvalidation } from './chunk-KXVHDK3Q.js';
2
+ import { useDataProvider } from './chunk-GGSXSI6Z.js';
3
+ import { useState, useCallback, useMemo } from 'react';
4
+ import { useInfiniteQuery } from '@tanstack/react-query';
5
+
6
+ function filterKey(filters) {
7
+ return JSON.stringify(
8
+ (filters ?? []).map(
9
+ (f) => Object.fromEntries(Object.entries(f).sort(([a], [b]) => a.localeCompare(b)))
10
+ )
11
+ );
12
+ }
13
+ function useInfiniteResource(resource, options = {}) {
14
+ const {
15
+ defaultSort,
16
+ defaultFilters,
17
+ perPage = 25,
18
+ defaultSearch = "",
19
+ select,
20
+ searchFields,
21
+ enabled = true,
22
+ live
23
+ } = options;
24
+ const provider = useDataProvider();
25
+ const [sort, setSort] = useState(defaultSort);
26
+ const [filters, setFiltersRaw] = useState(defaultFilters ?? []);
27
+ const [search, setSearch] = useState(defaultSearch);
28
+ const defaultKey = filterKey(defaultFilters);
29
+ const [syncedKey, setSyncedKey] = useState(defaultKey);
30
+ if (defaultKey !== syncedKey) {
31
+ setSyncedKey(defaultKey);
32
+ setFiltersRaw(defaultFilters ?? []);
33
+ }
34
+ const setFilters = useCallback((next) => setFiltersRaw(next), []);
35
+ const params = useMemo(() => {
36
+ const p = {
37
+ perPage,
38
+ sort,
39
+ filters: filters.length > 0 ? filters : void 0,
40
+ select
41
+ };
42
+ if (search && searchFields && searchFields.length > 0) {
43
+ p.search = { query: search, fields: searchFields };
44
+ }
45
+ return p;
46
+ }, [perPage, sort, filters, select, search, searchFields]);
47
+ const query = useInfiniteQuery({
48
+ queryKey: [resource, "infinite", params],
49
+ initialPageParam: { page: 1, cursor: null },
50
+ queryFn: ({ pageParam }) => {
51
+ const { perPage: size, ...rest } = params;
52
+ return provider.getList(resource, {
53
+ ...rest,
54
+ pagination: { page: pageParam.page, perPage: size, cursor: pageParam.cursor }
55
+ });
56
+ },
57
+ getNextPageParam: (last, pages, lastParam) => {
58
+ if (last.nextCursor !== void 0) {
59
+ return last.nextCursor ? { page: lastParam.page + 1, cursor: last.nextCursor } : void 0;
60
+ }
61
+ const loaded = pages.reduce((n, p) => n + p.data.length, 0);
62
+ const total2 = pages[0]?.total ?? 0;
63
+ if (last.data.length < params.perPage || loaded >= total2) return void 0;
64
+ return { page: lastParam.page + 1, cursor: null };
65
+ },
66
+ enabled
67
+ });
68
+ useLiveInvalidation(resource, live, enabled, { filters: params.filters }, [resource]);
69
+ const rows = useMemo(() => {
70
+ const seen = /* @__PURE__ */ new Set();
71
+ const out = [];
72
+ for (const page of query.data?.pages ?? []) {
73
+ for (const row of page.data) {
74
+ const id = row?.id;
75
+ if (id != null) {
76
+ const key = String(id);
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ }
80
+ out.push(row);
81
+ }
82
+ }
83
+ return out;
84
+ }, [query.data]);
85
+ const firstTotal = query.data?.pages[0]?.total;
86
+ const total = firstTotal === void 0 || firstTotal < 0 ? void 0 : firstTotal;
87
+ const { fetchNextPage: fetchNext, hasNextPage, isFetchingNextPage } = query;
88
+ const fetchNextPage = useCallback(() => {
89
+ if (!hasNextPage || isFetchingNextPage) return;
90
+ void fetchNext();
91
+ }, [fetchNext, hasNextPage, isFetchingNextPage]);
92
+ return {
93
+ query,
94
+ rows,
95
+ total,
96
+ fetchNextPage,
97
+ hasNextPage: !!hasNextPage,
98
+ isFetchingNextPage,
99
+ params,
100
+ setSort,
101
+ setFilters,
102
+ setSearch
103
+ };
104
+ }
105
+
106
+ export { useInfiniteResource };
107
+ //# sourceMappingURL=chunk-ADD2RIGA.js.map
108
+ //# sourceMappingURL=chunk-ADD2RIGA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-infinite-resource.ts"],"names":["total"],"mappings":"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","file":"chunk-ADD2RIGA.js","sourcesContent":["import { useCallback, useMemo, useState } from \"react\";\nimport {\n useInfiniteQuery,\n type InfiniteData,\n type UseInfiniteQueryResult,\n} from \"@tanstack/react-query\";\nimport { useDataProvider } from \"../provider/context\";\nimport type {\n BaseRecord,\n FilterParam,\n ListParams,\n ListResult,\n SortParam,\n} from \"../provider/types\";\nimport { useLiveInvalidation, type LiveOptions } from \"./use-live\";\n\n// ---------------------------------------------------------------------------\n// useInfiniteResource — a list that grows with \"Load more\", by cursor.\n//\n// Asks for the first page with `cursor: null`. A provider that serves cursor\n// paging answers with `nextCursor` and is paged by keyset from then on; one\n// that doesn't answers without it and is paged by `page` (offset) until\n// `total` rows are loaded. Pages live under `[resource, \"infinite\", params]`,\n// so writes on the resource (which invalidate `[resource]`) refetch them,\n// and pending optimistic and undoable writes are patched into every page.\n// ---------------------------------------------------------------------------\n\nexport interface UseInfiniteResourceOptions {\n defaultSort?: SortParam;\n /** Re-synced when its value changes, like `useResource`'s. */\n defaultFilters?: FilterParam[];\n /** Rows per page. Default 25. */\n perPage?: number;\n /** The search to start with. Default \"\". */\n defaultSearch?: string;\n /** Supabase select/join syntax. Must include the sort and key columns. */\n select?: string;\n /** Fields to search across when `setSearch` is called. */\n searchFields?: string[];\n /** Disable fetching. */\n enabled?: boolean;\n /** Refetch the loaded pages when the provider reports a change. See `UseResourceOptions.live`. */\n live?: boolean | LiveOptions;\n}\n\n/** Where one page starts: its offset page, and its cursor (`null` for the first). */\nexport interface InfinitePageParam {\n page: number;\n cursor: string | null;\n}\n\nexport interface UseInfiniteResourceReturn<T> {\n /** The TanStack infinite query. */\n query: UseInfiniteQueryResult<InfiniteData<ListResult<T>, InfinitePageParam>>;\n /** Every loaded row, in order. */\n rows: T[];\n /** Rows matching the filters and search, from the first page's count. */\n total: number | undefined;\n /** Loads the next page. Does nothing while one is loading or none is left. */\n fetchNextPage: () => void;\n hasNextPage: boolean;\n isFetchingNextPage: boolean;\n /** The params every page is asked with (without its page and cursor). */\n params: Omit<ListParams, \"pagination\"> & { perPage: number };\n setSort: (sort: SortParam | undefined) => void;\n setFilters: (filters: FilterParam[]) => void;\n setSearch: (query: string) => void;\n}\n\nfunction filterKey(filters: FilterParam[] | undefined): string {\n return JSON.stringify(\n (filters ?? []).map((f) =>\n Object.fromEntries(Object.entries(f).sort(([a], [b]) => a.localeCompare(b))),\n ),\n );\n}\n\nexport function useInfiniteResource<T extends BaseRecord = BaseRecord>(\n resource: string,\n options: UseInfiniteResourceOptions = {},\n): UseInfiniteResourceReturn<T> {\n const {\n defaultSort,\n defaultFilters,\n perPage = 25,\n defaultSearch = \"\",\n select,\n searchFields,\n enabled = true,\n live,\n } = options;\n const provider = useDataProvider();\n\n const [sort, setSort] = useState<SortParam | undefined>(defaultSort);\n const [filters, setFiltersRaw] = useState<FilterParam[]>(defaultFilters ?? []);\n const [search, setSearch] = useState(defaultSearch);\n\n // Follow `defaultFilters` by value, during render (see useResource).\n const defaultKey = filterKey(defaultFilters);\n const [syncedKey, setSyncedKey] = useState(defaultKey);\n if (defaultKey !== syncedKey) {\n setSyncedKey(defaultKey);\n setFiltersRaw(defaultFilters ?? []);\n }\n\n const setFilters = useCallback((next: FilterParam[]) => setFiltersRaw(next), []);\n\n const params = useMemo(() => {\n const p: Omit<ListParams, \"pagination\"> & { perPage: number } = {\n perPage,\n sort,\n filters: filters.length > 0 ? filters : undefined,\n select,\n };\n if (search && searchFields && searchFields.length > 0) {\n p.search = { query: search, fields: searchFields };\n }\n return p;\n }, [perPage, sort, filters, select, search, searchFields]);\n\n const query = useInfiniteQuery<\n ListResult<T>,\n Error,\n InfiniteData<ListResult<T>, InfinitePageParam>,\n readonly unknown[],\n InfinitePageParam\n >({\n queryKey: [resource, \"infinite\", params],\n initialPageParam: { page: 1, cursor: null },\n queryFn: ({ pageParam }) => {\n const { perPage: size, ...rest } = params;\n return provider.getList<T>(resource, {\n ...rest,\n pagination: { page: pageParam.page, perPage: size, cursor: pageParam.cursor },\n });\n },\n getNextPageParam: (last, pages, lastParam) => {\n if (last.nextCursor !== undefined) {\n // Cursor mode: the provider says whether there is more.\n return last.nextCursor ? { page: lastParam.page + 1, cursor: last.nextCursor } : undefined;\n }\n // Offset mode: more while fewer than `total` rows are loaded and the\n // last page was full.\n const loaded = pages.reduce((n, p) => n + p.data.length, 0);\n const total = pages[0]?.total ?? 0;\n if (last.data.length < params.perPage || loaded >= total) return undefined;\n return { page: lastParam.page + 1, cursor: null };\n },\n enabled,\n });\n\n useLiveInvalidation(resource, live, enabled, { filters: params.filters }, [resource]);\n\n // A row can move between pages while the user scrolls (its sort value\n // changed, or rows before it were added): show it once, where it first appears.\n const rows = useMemo(() => {\n const seen = new Set<string>();\n const out: T[] = [];\n for (const page of query.data?.pages ?? []) {\n for (const row of page.data) {\n const id = row?.id;\n if (id != null) {\n const key = String(id);\n if (seen.has(key)) continue;\n seen.add(key);\n }\n out.push(row);\n }\n }\n return out;\n }, [query.data]);\n const firstTotal = query.data?.pages[0]?.total;\n const total = firstTotal === undefined || firstTotal < 0 ? undefined : firstTotal;\n\n const { fetchNextPage: fetchNext, hasNextPage, isFetchingNextPage } = query;\n const fetchNextPage = useCallback(() => {\n if (!hasNextPage || isFetchingNextPage) return;\n void fetchNext();\n }, [fetchNext, hasNextPage, isFetchingNextPage]);\n\n return {\n query,\n rows,\n total,\n fetchNextPage,\n hasNextPage: !!hasNextPage,\n isFetchingNextPage,\n params,\n setSort,\n setFilters,\n setSearch,\n };\n}\n"]}
@@ -0,0 +1,71 @@
1
+ import { useDataProvider } from './chunk-GGSXSI6Z.js';
2
+ import { useRef, useEffect } from 'react';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+
5
+ var DEFAULT_LIVE_DEBOUNCE_MS = 250;
6
+ var warnedNoSubscribe = /* @__PURE__ */ new WeakSet();
7
+ function useLiveInvalidation(resource, live, enabled, params, queryKey, accept) {
8
+ const provider = useDataProvider();
9
+ const queryClient = useQueryClient();
10
+ const on = !!live && enabled;
11
+ const debounceMs = Math.max(
12
+ 0,
13
+ (typeof live === "object" ? live.debounceMs : void 0) ?? DEFAULT_LIVE_DEBOUNCE_MS
14
+ );
15
+ const paramsKey = JSON.stringify(params);
16
+ const keyKey = JSON.stringify(queryKey);
17
+ const latest = useRef({ queryKey, accept, params });
18
+ latest.current = { queryKey, accept, params };
19
+ useEffect(() => {
20
+ if (!on) return;
21
+ if (typeof provider.subscribe !== "function") {
22
+ if (process.env.NODE_ENV !== "production" && !warnedNoSubscribe.has(provider)) {
23
+ warnedNoSubscribe.add(provider);
24
+ console.warn(
25
+ `[genesis] "${resource}" asked for live updates, but the data provider has no subscribe(); the list will only refresh on its own reads.`
26
+ );
27
+ }
28
+ return;
29
+ }
30
+ let timer;
31
+ let burstStart = 0;
32
+ let active = true;
33
+ const maxWaitMs = debounceMs * 4;
34
+ const fire = () => {
35
+ timer = void 0;
36
+ if (!active) return;
37
+ const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
38
+ void queryClient.invalidateQueries({
39
+ queryKey: [...latest.current.queryKey],
40
+ refetchType: hidden ? "none" : "active"
41
+ });
42
+ };
43
+ let unsubscribe;
44
+ try {
45
+ unsubscribe = provider.subscribe(resource, latest.current.params, (event) => {
46
+ if (!active) return;
47
+ const test = latest.current.accept;
48
+ if (test && event.type !== "changed" && !test(event)) return;
49
+ const now = Date.now();
50
+ if (timer === void 0) burstStart = now;
51
+ else clearTimeout(timer);
52
+ timer = setTimeout(fire, Math.min(debounceMs, Math.max(0, burstStart + maxWaitMs - now)));
53
+ });
54
+ } catch (error) {
55
+ console.error(`[genesis] subscribing to "${resource}" failed:`, error);
56
+ }
57
+ return () => {
58
+ active = false;
59
+ if (timer !== void 0) clearTimeout(timer);
60
+ try {
61
+ unsubscribe?.();
62
+ } catch (error) {
63
+ console.error(`[genesis] unsubscribing from "${resource}" failed:`, error);
64
+ }
65
+ };
66
+ }, [on, provider, queryClient, resource, debounceMs, paramsKey, keyKey]);
67
+ }
68
+
69
+ export { DEFAULT_LIVE_DEBOUNCE_MS, useLiveInvalidation };
70
+ //# sourceMappingURL=chunk-KXVHDK3Q.js.map
71
+ //# sourceMappingURL=chunk-KXVHDK3Q.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-live.ts"],"names":[],"mappings":"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","file":"chunk-KXVHDK3Q.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Live reads — refetch a resource's cached reads when the provider reports a\n// change (`DataProvider.subscribe`).\n//\n// Events are invalidation hints only. Nothing from an event's payload is\n// written to the cache: the refetch goes through the provider's normal read,\n// so what a user sees is always what that read (and its RLS) returns.\n//\n// Pending optimistic and undoable writes are safe: their patches are\n// re-applied to whatever a refetch brings (see mutation-mode.ts), so a row\n// deleted with undo stays hidden and an edit stays shown until the write\n// settles. A change caused by this client's own write arrives as an event\n// too; the refetch it causes is idempotent, so it is not filtered out.\n// ---------------------------------------------------------------------------\n\nimport { useEffect, useRef } from \"react\";\nimport { useQueryClient, type QueryKey } from \"@tanstack/react-query\";\nimport { useDataProvider } from \"../provider/context\";\nimport type { ResourceChangeEvent, SubscribeParams } from \"../provider/types\";\n\n/** How long a burst of change events is gathered into one refetch. */\nexport const DEFAULT_LIVE_DEBOUNCE_MS = 250;\n\n/** `live` as an object, to tune it. `live: true` takes the defaults. */\nexport interface LiveOptions {\n /**\n * Events arriving within this many milliseconds of each other cause one\n * refetch, not one each. Default {@link DEFAULT_LIVE_DEBOUNCE_MS}. A\n * steady stream still refetches at least every `4 × debounceMs`.\n */\n debounceMs?: number;\n}\n\nconst warnedNoSubscribe = new WeakSet<object>();\n\n/**\n * Subscribes while mounted and `enabled`, and invalidates `queryKey` (a\n * prefix) once per burst of events that `accept` lets through. While the\n * page is hidden the queries are only marked stale; they refetch when it is\n * shown again (TanStack Query's `refetchOnWindowFocus`) or next used.\n *\n * @internal Used by `useResource` and `useOne`'s `live` option.\n */\nexport function useLiveInvalidation(\n resource: string,\n live: boolean | LiveOptions | undefined,\n enabled: boolean,\n params: SubscribeParams,\n queryKey: QueryKey,\n accept?: (event: ResourceChangeEvent) => boolean,\n): void {\n const provider = useDataProvider();\n const queryClient = useQueryClient();\n const on = !!live && enabled;\n const debounceMs = Math.max(\n 0,\n (typeof live === \"object\" ? live.debounceMs : undefined) ?? DEFAULT_LIVE_DEBOUNCE_MS,\n );\n const paramsKey = JSON.stringify(params);\n const keyKey = JSON.stringify(queryKey);\n\n // The latest values, read when an event arrives, so a new array or\n // callback each render doesn't resubscribe.\n const latest = useRef({ queryKey, accept, params });\n latest.current = { queryKey, accept, params };\n\n useEffect(() => {\n if (!on) return;\n if (typeof provider.subscribe !== \"function\") {\n if (process.env.NODE_ENV !== \"production\" && !warnedNoSubscribe.has(provider)) {\n warnedNoSubscribe.add(provider);\n console.warn(\n `[genesis] \"${resource}\" asked for live updates, but the data provider has no subscribe(); the list will only refresh on its own reads.`,\n );\n }\n return;\n }\n let timer: ReturnType<typeof setTimeout> | undefined;\n let burstStart = 0;\n let active = true;\n // A busy table must not postpone the refetch forever.\n const maxWaitMs = debounceMs * 4;\n const fire = () => {\n timer = undefined;\n if (!active) return;\n const hidden = typeof document !== \"undefined\" && document.visibilityState === \"hidden\";\n void queryClient.invalidateQueries({\n queryKey: [...latest.current.queryKey],\n refetchType: hidden ? \"none\" : \"active\",\n });\n };\n let unsubscribe: (() => void) | undefined;\n try {\n unsubscribe = provider.subscribe(resource, latest.current.params, (event) => {\n if (!active) return;\n const test = latest.current.accept;\n if (test && event.type !== \"changed\" && !test(event)) return;\n // Coalesce: one refetch per burst, after it goes quiet (or has\n // lasted `maxWaitMs`).\n const now = Date.now();\n if (timer === undefined) burstStart = now;\n else clearTimeout(timer);\n timer = setTimeout(fire, Math.min(debounceMs, Math.max(0, burstStart + maxWaitMs - now)));\n });\n } catch (error) {\n console.error(`[genesis] subscribing to \"${resource}\" failed:`, error);\n }\n return () => {\n active = false;\n if (timer !== undefined) clearTimeout(timer);\n try {\n unsubscribe?.();\n } catch (error) {\n console.error(`[genesis] unsubscribing from \"${resource}\" failed:`, error);\n }\n };\n // `paramsKey` / `keyKey` stand for `params` / `queryKey` by value.\n }, [on, provider, queryClient, resource, debounceMs, paramsKey, keyKey]);\n}\n"]}
@@ -8,6 +8,36 @@ function sameId(a, b) {
8
8
  function isListResult(value) {
9
9
  return typeof value === "object" && value !== null && Array.isArray(value.data);
10
10
  }
11
+ function isInfiniteLists(value) {
12
+ return typeof value === "object" && value !== null && Array.isArray(value.pages) && Array.isArray(value.pageParams);
13
+ }
14
+ function mapLists(key, hash, value, fn) {
15
+ if (key[1] === "list") return isListResult(value) ? fn(value, hash) : value;
16
+ if (key[1] !== "infinite" || !isInfiniteLists(value)) return value;
17
+ let changed = false;
18
+ let rowDelta = 0;
19
+ const pages = value.pages.map((page, i) => {
20
+ if (!isListResult(page)) return page;
21
+ const next = fn(page, `${hash}#${i}`);
22
+ if (next !== page) {
23
+ changed = true;
24
+ rowDelta += next.data.length - page.data.length;
25
+ }
26
+ return next;
27
+ });
28
+ if (!changed) return value;
29
+ const withTotals = value.pages.map((orig, i) => {
30
+ const page = pages[i];
31
+ if (!isListResult(page) || !isListResult(orig) || typeof orig.total !== "number") return page;
32
+ return { ...page, total: orig.total < 0 ? orig.total : Math.max(0, orig.total + rowDelta) };
33
+ });
34
+ return { ...value, pages: withTotals };
35
+ }
36
+ function listsIn(key, value) {
37
+ if (key[1] === "list") return isListResult(value) ? [value] : [];
38
+ if (key[1] === "infinite" && isInfiniteLists(value)) return value.pages.filter(isListResult);
39
+ return [];
40
+ }
11
41
  function isDetailKeyFor(key, resource, id) {
12
42
  return key[0] === resource && key[1] === "detail" && sameId(key[2], id);
13
43
  }
@@ -34,10 +64,11 @@ function cachedRecord(queryClient, resource, id) {
34
64
  for (const [key, value] of queryClient.getQueriesData({ queryKey: [resource, "detail"] })) {
35
65
  if (isDetailKeyFor(key, resource, id) && value && typeof value === "object") return value;
36
66
  }
37
- for (const [, value] of queryClient.getQueriesData({ queryKey: [resource, "list"] })) {
38
- if (!isListResult(value)) continue;
39
- const found = value.data.find((row) => sameId(row.id, id));
40
- if (found) return found;
67
+ for (const [key, value] of queryClient.getQueriesData({ queryKey: [resource] })) {
68
+ for (const list of listsIn(key, value)) {
69
+ const found = list.data.find((row) => sameId(row.id, id));
70
+ if (found) return found;
71
+ }
41
72
  }
42
73
  return void 0;
43
74
  }
@@ -96,25 +127,27 @@ function revertFields(row, data, before) {
96
127
  return out;
97
128
  }
98
129
  function applyPatches(key, hash, value, patches) {
99
- if (key[1] === "list" && isListResult(value)) {
100
- let rows = value.data;
101
- let total = value.total;
102
- let changed = false;
103
- for (const p of patches) {
104
- const index = rows.findIndex((row) => sameId(row.id, p.id));
105
- if (index < 0) continue;
106
- p.before.set(hash, { row: rows[index], index });
107
- if (p.kind === "update") {
108
- rows = rows.slice();
109
- rows[index] = applyUpdate(rows[index], p.data ?? {});
110
- } else {
111
- rows = rows.filter((_, i) => i !== index);
112
- if (typeof total === "number") total = Math.max(0, total - 1);
130
+ if (key[1] === "list" || key[1] === "infinite") {
131
+ return mapLists(key, hash, value, (list, listHash) => {
132
+ let rows = list.data;
133
+ let total = list.total;
134
+ let changed = false;
135
+ for (const p of patches) {
136
+ const index = rows.findIndex((row) => sameId(row.id, p.id));
137
+ if (index < 0) continue;
138
+ p.before.set(listHash, { row: rows[index], index });
139
+ if (p.kind === "update") {
140
+ rows = rows.slice();
141
+ rows[index] = applyUpdate(rows[index], p.data ?? {});
142
+ } else {
143
+ rows = rows.filter((_, i) => i !== index);
144
+ if (typeof total === "number" && total >= 0) total = Math.max(0, total - 1);
145
+ }
146
+ changed = true;
113
147
  }
114
- changed = true;
115
- }
116
- if (!changed) return value;
117
- return typeof total === "number" ? { ...value, data: rows, total } : { ...value, data: rows };
148
+ if (!changed) return list;
149
+ return typeof total === "number" ? { ...list, data: rows, total } : { ...list, data: rows };
150
+ });
118
151
  }
119
152
  if (key[1] === "detail" && value && typeof value === "object") {
120
153
  let row = value;
@@ -183,28 +216,29 @@ function retractPatch(reg, queryClient, patch) {
183
216
  for (const query of queryClient.getQueryCache().findAll({ queryKey: [patch.resource] })) {
184
217
  const key = query.queryKey;
185
218
  const value = query.state.data;
186
- const b = patch.before.get(query.queryHash);
187
219
  let next = value;
188
- if (key[1] === "list" && isListResult(value)) {
189
- const at = value.data.findIndex((row) => sameId(row.id, patch.id));
190
- if (patch.kind === "update") {
191
- const src = b ?? firstBefore(patch);
192
- if (at < 0 || !src || !patch.data) continue;
193
- const rows = value.data.slice();
194
- rows[at] = reapply(revertFields(rows[at], patch.data, src.row));
195
- next = { ...value, data: rows };
196
- } else {
197
- if (at >= 0 || !b || remaining.some((p) => p.kind === "delete")) continue;
198
- const rows = value.data.slice();
220
+ if (key[1] === "list" || key[1] === "infinite") {
221
+ next = mapLists(key, query.queryHash, value, (list, listHash) => {
222
+ const b = patch.before.get(listHash);
223
+ const at = list.data.findIndex((row) => sameId(row.id, patch.id));
224
+ if (patch.kind === "update") {
225
+ const src = b ?? firstBefore(patch);
226
+ if (at < 0 || !src || !patch.data) return list;
227
+ const rows2 = list.data.slice();
228
+ rows2[at] = reapply(revertFields(rows2[at], patch.data, src.row));
229
+ return { ...list, data: rows2 };
230
+ }
231
+ if (at >= 0 || !b || remaining.some((p) => p.kind === "delete")) return list;
232
+ const rows = list.data.slice();
199
233
  rows.splice(Math.min(b.index, rows.length), 0, reapply(b.row));
200
- next = {
201
- ...value,
234
+ return {
235
+ ...list,
202
236
  data: rows,
203
- ...typeof value.total === "number" ? { total: value.total + 1 } : {}
237
+ ...typeof list.total === "number" && list.total >= 0 ? { total: list.total + 1 } : {}
204
238
  };
205
- }
239
+ });
206
240
  } else if (key[1] === "detail" && patch.kind === "update" && patch.data && sameId(key[2], patch.id) && value && typeof value === "object") {
207
- const src = b ?? firstBefore(patch);
241
+ const src = patch.before.get(query.queryHash) ?? firstBefore(patch);
208
242
  if (!src) continue;
209
243
  next = reapply(revertFields(value, patch.data, src.row));
210
244
  }
@@ -266,12 +300,14 @@ function refreshFields(reg, queryClient, patch, keys) {
266
300
  const key = query.queryKey;
267
301
  const value = query.state.data;
268
302
  let next = value;
269
- if (key[1] === "list" && isListResult(value)) {
270
- const at = value.data.findIndex((row) => sameId(row.id, patch.id));
271
- if (at < 0) continue;
272
- const rows = value.data.slice();
273
- rows[at] = fix(rows[at], query.queryHash);
274
- next = { ...value, data: rows };
303
+ if (key[1] === "list" || key[1] === "infinite") {
304
+ next = mapLists(key, query.queryHash, value, (list, listHash) => {
305
+ const at = list.data.findIndex((row) => sameId(row.id, patch.id));
306
+ if (at < 0) return list;
307
+ const rows = list.data.slice();
308
+ rows[at] = fix(rows[at], listHash);
309
+ return { ...list, data: rows };
310
+ });
275
311
  } else if (key[1] === "detail" && sameId(key[2], patch.id) && value && typeof value === "object") {
276
312
  next = fix(value, query.queryHash);
277
313
  }
@@ -516,5 +552,5 @@ async function executeWrite(args) {
516
552
  }
517
553
 
518
554
  export { DEFAULT_UNDO_DELAY_MS, DROPPED_WRITE_MESSAGE, cachedRecord, executeWrite, invalidateResource, pendingWriteCount, providerServesUndo, sameValue };
519
- //# sourceMappingURL=chunk-F3BE7NGM.js.map
520
- //# sourceMappingURL=chunk-F3BE7NGM.js.map
555
+ //# sourceMappingURL=chunk-OBUBP4BY.js.map
556
+ //# sourceMappingURL=chunk-OBUBP4BY.js.map