@trylight/app 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ unchanged; entities and the schema sync are new here.
8
8
  | Import | What |
9
9
  |---|---|
10
10
  | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
11
- | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
11
+ | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
12
  | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
13
  | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
@@ -27,9 +27,17 @@ edited value loses its source; sources sent are merged by column. Lists sort
27
27
  by the asked column, then id. With no sign-in configured only a non-production
28
28
  server asked on loopback answers (as "Local developer"); anything else gets 503.
29
29
 
30
- Pages read and write through TanStack Query hooks: `useUpdate` patches every
31
- cached list and row at once and stays pending until the table's refetch lands
32
- (no snap-back); a failed save is corrected by that same refetch.
30
+ Pages read and write through TanStack Query hooks. `useUpdate` and
31
+ `useDelete` change every query under the table's key at once (`useList`,
32
+ `useInfiniteList`, `useGet`, and a page's own query keyed under it) and put
33
+ them back the moment a save fails; a saved write stays pending until the
34
+ table's refetch lands (no snap-back). `useInfiniteList` pages by `offset`
35
+ (the next page when one comes back full), keeps the last options' rows while
36
+ new ones load (`isSwitching`), sorts on the server, and prefetches the
37
+ sibling views it is given once it settles. A write refetches every page a
38
+ list has loaded, one after another, before its mutation settles; the
39
+ change itself shows at once. `search` matches words in any
40
+ text column, case-insensitively, on the server.
33
41
 
34
42
  Joins and aggregates stay typed server functions over `light.db`.
35
43
 
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type QueryKey } from "@tanstack/react-query";
1
2
  import type { Contracts } from "@shubh90/app-runtime/invoke/server";
2
3
  import { EntityError, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
3
4
  import type { Entity } from "./schema.js";
@@ -10,6 +11,58 @@ export declare function createApp<C extends Contracts = Record<never, never>>():
10
11
  table: typeof tableClient;
11
12
  contracts: import("@shubh90/app-runtime/invoke").Invoker<C>;
12
13
  };
14
+ /** Rows one page of `useInfiniteList` asks for; scrolling near the end asks for the next. */
15
+ export declare const PAGE_SIZE = 100;
16
+ /** A column, and which way: what a table header sorts by. */
17
+ export type Sorting = {
18
+ column: string;
19
+ desc: boolean;
20
+ };
21
+ /** What `useInfiniteList` takes: a list's options, less the paging it does itself. */
22
+ export type PageOptions<R> = Omit<ListOptions<R>, "limit" | "offset"> & {
23
+ /** Rows per page; PAGE_SIZE unless a page needs fewer or more. */
24
+ pageSize?: number;
25
+ };
26
+ /**
27
+ * A list as a table draws it (DataTable's `list`): its rows, whether more
28
+ * exist, and how to get them. `useList` and `useInfiniteList` both give one.
29
+ */
30
+ export type ListView<R> = {
31
+ rows: R[];
32
+ /** Nothing to show yet: the first answer is on its way. */
33
+ isPending: boolean;
34
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
35
+ loadError: Error | null;
36
+ refetch: () => unknown;
37
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
38
+ hasMore: boolean;
39
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
40
+ loadMore: (() => void) | null;
41
+ isLoadingMore: boolean;
42
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
43
+ moreFailed: boolean;
44
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
45
+ isSwitching: boolean;
46
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
47
+ serverSort: {
48
+ sorting: Sorting | null;
49
+ setSorting: (next: Sorting | null) => void;
50
+ canSort: (column: string) => boolean;
51
+ } | null;
52
+ };
53
+ /**
54
+ * Where one row sat in a cached query before a write: its value, page and
55
+ * place, to put it back if the write fails. `fields` are the ones the write
56
+ * changed (an update's patch), so a failure takes back only those and never
57
+ * another save's change to the same row; null (a delete) is the whole row.
58
+ */
59
+ type Before = {
60
+ key: QueryKey;
61
+ page: number;
62
+ index: number;
63
+ row: unknown;
64
+ fields: string[] | null;
65
+ };
13
66
  /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
14
67
  declare function tableClient<T extends Entity>(entity: T): {
15
68
  /**
@@ -31,10 +84,29 @@ declare function tableClient<T extends Entity>(entity: T): {
31
84
  */
32
85
  useList: (options?: ListOptions<Row<T>>) => {
33
86
  rows: (T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never)[];
87
+ /** Nothing to show yet: the first answer is on its way. */
88
+ isPending: boolean;
89
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
90
+ loadError: Error | null;
91
+ refetch: () => unknown;
92
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
93
+ hasMore: boolean;
94
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
95
+ loadMore: (() => void) | null;
96
+ isLoadingMore: boolean;
97
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
98
+ moreFailed: boolean;
99
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
100
+ isSwitching: boolean;
101
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
102
+ serverSort: {
103
+ sorting: Sorting | null;
104
+ setSorting: (next: Sorting | null) => void;
105
+ canSort: (column: string) => boolean;
106
+ } | null;
34
107
  data: (T["$inferSelect"] extends infer T_2 ? { [K in keyof T_2]: Date extends T_2[K] ? string | Exclude<T_2[K], T_2[K] & Date> : T_2[K]; } : never)[];
35
108
  error: EntityError;
36
109
  isError: true;
37
- isPending: false;
38
110
  isLoading: false;
39
111
  isLoadingError: false;
40
112
  isRefetchError: true;
@@ -54,14 +126,32 @@ declare function tableClient<T extends Entity>(entity: T): {
54
126
  isRefetching: boolean;
55
127
  isStale: boolean;
56
128
  isEnabled: boolean;
57
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[], EntityError>>;
58
129
  fetchStatus: import("@tanstack/react-query").FetchStatus;
59
130
  } | {
60
131
  rows: (T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[];
132
+ /** Nothing to show yet: the first answer is on its way. */
133
+ isPending: boolean;
134
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
135
+ loadError: Error | null;
136
+ refetch: () => unknown;
137
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
138
+ hasMore: boolean;
139
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
140
+ loadMore: (() => void) | null;
141
+ isLoadingMore: boolean;
142
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
143
+ moreFailed: boolean;
144
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
145
+ isSwitching: boolean;
146
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
147
+ serverSort: {
148
+ sorting: Sorting | null;
149
+ setSorting: (next: Sorting | null) => void;
150
+ canSort: (column: string) => boolean;
151
+ } | null;
61
152
  data: (T["$inferSelect"] extends infer T_4 ? { [K in keyof T_4]: Date extends T_4[K] ? string | Exclude<T_4[K], T_4[K] & Date> : T_4[K]; } : never)[];
62
153
  error: null;
63
154
  isError: false;
64
- isPending: false;
65
155
  isLoading: false;
66
156
  isLoadingError: false;
67
157
  isRefetchError: false;
@@ -81,14 +171,32 @@ declare function tableClient<T extends Entity>(entity: T): {
81
171
  isRefetching: boolean;
82
172
  isStale: boolean;
83
173
  isEnabled: boolean;
84
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[], EntityError>>;
85
174
  fetchStatus: import("@tanstack/react-query").FetchStatus;
86
175
  } | {
87
176
  rows: (T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[];
177
+ /** Nothing to show yet: the first answer is on its way. */
178
+ isPending: boolean;
179
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
180
+ loadError: Error | null;
181
+ refetch: () => unknown;
182
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
183
+ hasMore: boolean;
184
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
185
+ loadMore: (() => void) | null;
186
+ isLoadingMore: boolean;
187
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
188
+ moreFailed: boolean;
189
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
190
+ isSwitching: boolean;
191
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
192
+ serverSort: {
193
+ sorting: Sorting | null;
194
+ setSorting: (next: Sorting | null) => void;
195
+ canSort: (column: string) => boolean;
196
+ } | null;
88
197
  data: undefined;
89
198
  error: EntityError;
90
199
  isError: true;
91
- isPending: false;
92
200
  isLoading: false;
93
201
  isLoadingError: true;
94
202
  isRefetchError: false;
@@ -108,14 +216,32 @@ declare function tableClient<T extends Entity>(entity: T): {
108
216
  isRefetching: boolean;
109
217
  isStale: boolean;
110
218
  isEnabled: boolean;
111
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[], EntityError>>;
112
219
  fetchStatus: import("@tanstack/react-query").FetchStatus;
113
220
  } | {
114
221
  rows: (T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[];
222
+ /** Nothing to show yet: the first answer is on its way. */
223
+ isPending: boolean;
224
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
225
+ loadError: Error | null;
226
+ refetch: () => unknown;
227
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
228
+ hasMore: boolean;
229
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
230
+ loadMore: (() => void) | null;
231
+ isLoadingMore: boolean;
232
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
233
+ moreFailed: boolean;
234
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
235
+ isSwitching: boolean;
236
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
237
+ serverSort: {
238
+ sorting: Sorting | null;
239
+ setSorting: (next: Sorting | null) => void;
240
+ canSort: (column: string) => boolean;
241
+ } | null;
115
242
  data: undefined;
116
243
  error: null;
117
244
  isError: false;
118
- isPending: true;
119
245
  isLoading: true;
120
246
  isLoadingError: false;
121
247
  isRefetchError: false;
@@ -135,14 +261,32 @@ declare function tableClient<T extends Entity>(entity: T): {
135
261
  isRefetching: boolean;
136
262
  isStale: boolean;
137
263
  isEnabled: boolean;
138
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[], EntityError>>;
139
264
  fetchStatus: import("@tanstack/react-query").FetchStatus;
140
265
  } | {
141
266
  rows: (T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[];
267
+ /** Nothing to show yet: the first answer is on its way. */
268
+ isPending: boolean;
269
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
270
+ loadError: Error | null;
271
+ refetch: () => unknown;
272
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
273
+ hasMore: boolean;
274
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
275
+ loadMore: (() => void) | null;
276
+ isLoadingMore: boolean;
277
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
278
+ moreFailed: boolean;
279
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
280
+ isSwitching: boolean;
281
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
282
+ serverSort: {
283
+ sorting: Sorting | null;
284
+ setSorting: (next: Sorting | null) => void;
285
+ canSort: (column: string) => boolean;
286
+ } | null;
142
287
  data: undefined;
143
288
  error: null;
144
289
  isError: false;
145
- isPending: true;
146
290
  isLoadingError: false;
147
291
  isRefetchError: false;
148
292
  isSuccess: false;
@@ -162,14 +306,32 @@ declare function tableClient<T extends Entity>(entity: T): {
162
306
  isRefetching: boolean;
163
307
  isStale: boolean;
164
308
  isEnabled: boolean;
165
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[], EntityError>>;
166
309
  fetchStatus: import("@tanstack/react-query").FetchStatus;
167
310
  } | {
168
311
  rows: (T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[];
312
+ /** Nothing to show yet: the first answer is on its way. */
313
+ isPending: boolean;
314
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
315
+ loadError: Error | null;
316
+ refetch: () => unknown;
317
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
318
+ hasMore: boolean;
319
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
320
+ loadMore: (() => void) | null;
321
+ isLoadingMore: boolean;
322
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
323
+ moreFailed: boolean;
324
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
325
+ isSwitching: boolean;
326
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
327
+ serverSort: {
328
+ sorting: Sorting | null;
329
+ setSorting: (next: Sorting | null) => void;
330
+ canSort: (column: string) => boolean;
331
+ } | null;
169
332
  data: (T["$inferSelect"] extends infer T_9 ? { [K in keyof T_9]: Date extends T_9[K] ? string | Exclude<T_9[K], T_9[K] & Date> : T_9[K]; } : never)[];
170
333
  isError: false;
171
334
  error: null;
172
- isPending: false;
173
335
  isLoading: false;
174
336
  isLoadingError: false;
175
337
  isRefetchError: false;
@@ -189,19 +351,355 @@ declare function tableClient<T extends Entity>(entity: T): {
189
351
  isRefetching: boolean;
190
352
  isStale: boolean;
191
353
  isEnabled: boolean;
192
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<(T["$inferSelect"] extends infer T_10 ? { [K in keyof T_10]: Date extends T_10[K] ? string | Exclude<T_10[K], T_10[K] & Date> : T_10[K]; } : never)[], EntityError>>;
354
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
355
+ };
356
+ /**
357
+ * A list of any size, a page at a time: `loadMore` (DataTable calls it as
358
+ * the end scrolls into view) fetches the next. New options (a tab, a
359
+ * filter, a search) keep the last rows on screen with `isSwitching` until
360
+ * theirs land, so a click never blanks the table. `prefetch` names the
361
+ * sibling views (the other tabs): their first pages load once this one
362
+ * settles, so switching to one is instant. Sorting is the server's, over
363
+ * every row; `sort` is where it starts.
364
+ *
365
+ * const list = app.table(leads).useInfiniteList({ where: { status: tab } }, { prefetch: TABS.map((t) => ({ where: { status: t } })) });
366
+ * <DataTable list={list} … />
367
+ */
368
+ useInfiniteList: (options?: PageOptions<Row<T>>, { prefetch }?: {
369
+ prefetch?: PageOptions<Row<T>>[];
370
+ }) => {
371
+ rows: (T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never)[];
372
+ /** Nothing to show yet: the first answer is on its way. */
373
+ isPending: boolean;
374
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
375
+ loadError: Error | null;
376
+ refetch: () => unknown;
377
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
378
+ hasMore: boolean;
379
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
380
+ loadMore: (() => void) | null;
381
+ isLoadingMore: boolean;
382
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
383
+ moreFailed: boolean;
384
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
385
+ isSwitching: boolean;
386
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
387
+ serverSort: {
388
+ sorting: Sorting | null;
389
+ setSorting: (next: Sorting | null) => void;
390
+ canSort: (column: string) => boolean;
391
+ } | null;
392
+ data: import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_2 ? { [K in keyof T_2]: Date extends T_2[K] ? string | Exclude<T_2[K], T_2[K] & Date> : T_2[K]; } : never)[], unknown>;
393
+ error: Error;
394
+ isError: true;
395
+ isLoading: false;
396
+ isLoadingError: false;
397
+ isRefetchError: true;
398
+ isSuccess: false;
399
+ isPlaceholderData: false;
400
+ status: "error";
401
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[], unknown>, Error>>;
402
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[], unknown>, Error>>;
403
+ hasNextPage: boolean;
404
+ hasPreviousPage: boolean;
405
+ isFetchNextPageError: boolean;
406
+ isFetchingNextPage: boolean;
407
+ isFetchPreviousPageError: boolean;
408
+ isFetchingPreviousPage: boolean;
409
+ dataUpdatedAt: number;
410
+ errorUpdatedAt: number;
411
+ failureCount: number;
412
+ failureReason: Error | null;
413
+ errorUpdateCount: number;
414
+ isFetched: boolean;
415
+ isFetchedAfterMount: boolean;
416
+ isFetching: boolean;
417
+ isInitialLoading: boolean;
418
+ isPaused: boolean;
419
+ isRefetching: boolean;
420
+ isStale: boolean;
421
+ isEnabled: boolean;
422
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
423
+ } | {
424
+ rows: (T["$inferSelect"] extends infer T_3 ? { [K in keyof T_3]: Date extends T_3[K] ? string | Exclude<T_3[K], T_3[K] & Date> : T_3[K]; } : never)[];
425
+ /** Nothing to show yet: the first answer is on its way. */
426
+ isPending: boolean;
427
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
428
+ loadError: Error | null;
429
+ refetch: () => unknown;
430
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
431
+ hasMore: boolean;
432
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
433
+ loadMore: (() => void) | null;
434
+ isLoadingMore: boolean;
435
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
436
+ moreFailed: boolean;
437
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
438
+ isSwitching: boolean;
439
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
440
+ serverSort: {
441
+ sorting: Sorting | null;
442
+ setSorting: (next: Sorting | null) => void;
443
+ canSort: (column: string) => boolean;
444
+ } | null;
445
+ data: import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_4 ? { [K in keyof T_4]: Date extends T_4[K] ? string | Exclude<T_4[K], T_4[K] & Date> : T_4[K]; } : never)[], unknown>;
446
+ error: null;
447
+ isError: false;
448
+ isLoading: false;
449
+ isLoadingError: false;
450
+ isRefetchError: false;
451
+ isFetchNextPageError: false;
452
+ isFetchPreviousPageError: false;
453
+ isSuccess: true;
454
+ isPlaceholderData: false;
455
+ status: "success";
456
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[], unknown>, Error>>;
457
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[], unknown>, Error>>;
458
+ hasNextPage: boolean;
459
+ hasPreviousPage: boolean;
460
+ isFetchingNextPage: boolean;
461
+ isFetchingPreviousPage: boolean;
462
+ dataUpdatedAt: number;
463
+ errorUpdatedAt: number;
464
+ failureCount: number;
465
+ failureReason: Error | null;
466
+ errorUpdateCount: number;
467
+ isFetched: boolean;
468
+ isFetchedAfterMount: boolean;
469
+ isFetching: boolean;
470
+ isInitialLoading: boolean;
471
+ isPaused: boolean;
472
+ isRefetching: boolean;
473
+ isStale: boolean;
474
+ isEnabled: boolean;
475
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
476
+ } | {
477
+ rows: (T["$inferSelect"] extends infer T_5 ? { [K in keyof T_5]: Date extends T_5[K] ? string | Exclude<T_5[K], T_5[K] & Date> : T_5[K]; } : never)[];
478
+ /** Nothing to show yet: the first answer is on its way. */
479
+ isPending: boolean;
480
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
481
+ loadError: Error | null;
482
+ refetch: () => unknown;
483
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
484
+ hasMore: boolean;
485
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
486
+ loadMore: (() => void) | null;
487
+ isLoadingMore: boolean;
488
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
489
+ moreFailed: boolean;
490
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
491
+ isSwitching: boolean;
492
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
493
+ serverSort: {
494
+ sorting: Sorting | null;
495
+ setSorting: (next: Sorting | null) => void;
496
+ canSort: (column: string) => boolean;
497
+ } | null;
498
+ data: undefined;
499
+ error: Error;
500
+ isError: true;
501
+ isLoading: false;
502
+ isLoadingError: true;
503
+ isRefetchError: false;
504
+ isFetchNextPageError: false;
505
+ isFetchPreviousPageError: false;
506
+ isSuccess: false;
507
+ isPlaceholderData: false;
508
+ status: "error";
509
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[], unknown>, Error>>;
510
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[], unknown>, Error>>;
511
+ hasNextPage: boolean;
512
+ hasPreviousPage: boolean;
513
+ isFetchingNextPage: boolean;
514
+ isFetchingPreviousPage: boolean;
515
+ dataUpdatedAt: number;
516
+ errorUpdatedAt: number;
517
+ failureCount: number;
518
+ failureReason: Error | null;
519
+ errorUpdateCount: number;
520
+ isFetched: boolean;
521
+ isFetchedAfterMount: boolean;
522
+ isFetching: boolean;
523
+ isInitialLoading: boolean;
524
+ isPaused: boolean;
525
+ isRefetching: boolean;
526
+ isStale: boolean;
527
+ isEnabled: boolean;
528
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
529
+ } | {
530
+ rows: (T["$inferSelect"] extends infer T_6 ? { [K in keyof T_6]: Date extends T_6[K] ? string | Exclude<T_6[K], T_6[K] & Date> : T_6[K]; } : never)[];
531
+ /** Nothing to show yet: the first answer is on its way. */
532
+ isPending: boolean;
533
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
534
+ loadError: Error | null;
535
+ refetch: () => unknown;
536
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
537
+ hasMore: boolean;
538
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
539
+ loadMore: (() => void) | null;
540
+ isLoadingMore: boolean;
541
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
542
+ moreFailed: boolean;
543
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
544
+ isSwitching: boolean;
545
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
546
+ serverSort: {
547
+ sorting: Sorting | null;
548
+ setSorting: (next: Sorting | null) => void;
549
+ canSort: (column: string) => boolean;
550
+ } | null;
551
+ data: undefined;
552
+ error: null;
553
+ isError: false;
554
+ isLoading: true;
555
+ isLoadingError: false;
556
+ isRefetchError: false;
557
+ isFetchNextPageError: false;
558
+ isFetchPreviousPageError: false;
559
+ isSuccess: false;
560
+ isPlaceholderData: false;
561
+ status: "pending";
562
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[], unknown>, Error>>;
563
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[], unknown>, Error>>;
564
+ hasNextPage: boolean;
565
+ hasPreviousPage: boolean;
566
+ isFetchingNextPage: boolean;
567
+ isFetchingPreviousPage: boolean;
568
+ dataUpdatedAt: number;
569
+ errorUpdatedAt: number;
570
+ failureCount: number;
571
+ failureReason: Error | null;
572
+ errorUpdateCount: number;
573
+ isFetched: boolean;
574
+ isFetchedAfterMount: boolean;
575
+ isFetching: boolean;
576
+ isInitialLoading: boolean;
577
+ isPaused: boolean;
578
+ isRefetching: boolean;
579
+ isStale: boolean;
580
+ isEnabled: boolean;
581
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
582
+ } | {
583
+ rows: (T["$inferSelect"] extends infer T_7 ? { [K in keyof T_7]: Date extends T_7[K] ? string | Exclude<T_7[K], T_7[K] & Date> : T_7[K]; } : never)[];
584
+ /** Nothing to show yet: the first answer is on its way. */
585
+ isPending: boolean;
586
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
587
+ loadError: Error | null;
588
+ refetch: () => unknown;
589
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
590
+ hasMore: boolean;
591
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
592
+ loadMore: (() => void) | null;
593
+ isLoadingMore: boolean;
594
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
595
+ moreFailed: boolean;
596
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
597
+ isSwitching: boolean;
598
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
599
+ serverSort: {
600
+ sorting: Sorting | null;
601
+ setSorting: (next: Sorting | null) => void;
602
+ canSort: (column: string) => boolean;
603
+ } | null;
604
+ data: undefined;
605
+ error: null;
606
+ isError: false;
607
+ isLoadingError: false;
608
+ isRefetchError: false;
609
+ isFetchNextPageError: false;
610
+ isFetchPreviousPageError: false;
611
+ isSuccess: false;
612
+ isPlaceholderData: false;
613
+ status: "pending";
614
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[], unknown>, Error>>;
615
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[], unknown>, Error>>;
616
+ hasNextPage: boolean;
617
+ hasPreviousPage: boolean;
618
+ isFetchingNextPage: boolean;
619
+ isFetchingPreviousPage: boolean;
620
+ dataUpdatedAt: number;
621
+ errorUpdatedAt: number;
622
+ failureCount: number;
623
+ failureReason: Error | null;
624
+ errorUpdateCount: number;
625
+ isFetched: boolean;
626
+ isFetchedAfterMount: boolean;
627
+ isFetching: boolean;
628
+ isLoading: boolean;
629
+ isInitialLoading: boolean;
630
+ isPaused: boolean;
631
+ isRefetching: boolean;
632
+ isStale: boolean;
633
+ isEnabled: boolean;
634
+ fetchStatus: import("@tanstack/react-query").FetchStatus;
635
+ } | {
636
+ rows: (T["$inferSelect"] extends infer T_8 ? { [K in keyof T_8]: Date extends T_8[K] ? string | Exclude<T_8[K], T_8[K] & Date> : T_8[K]; } : never)[];
637
+ /** Nothing to show yet: the first answer is on its way. */
638
+ isPending: boolean;
639
+ /** Why the first answer failed (a refetch that fails keeps the rows); `refetch` asks again. */
640
+ loadError: Error | null;
641
+ refetch: () => unknown;
642
+ /** More rows exist than are loaded (useList: it stopped at its limit). */
643
+ hasMore: boolean;
644
+ /** Loads the next page; null when this list can't (useList: raise the limit or page it). */
645
+ loadMore: (() => void) | null;
646
+ isLoadingMore: boolean;
647
+ /** The last page asked for failed: `loadMore` asks again, and nothing asks by itself until then. */
648
+ moreFailed: boolean;
649
+ /** Showing the last options' rows while the new options' load (a tab or filter just changed). */
650
+ isSwitching: boolean;
651
+ /** Sorted by the server, which sees every row; null when the table sorts what's loaded. */
652
+ serverSort: {
653
+ sorting: Sorting | null;
654
+ setSorting: (next: Sorting | null) => void;
655
+ canSort: (column: string) => boolean;
656
+ } | null;
657
+ data: import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_9 ? { [K in keyof T_9]: Date extends T_9[K] ? string | Exclude<T_9[K], T_9[K] & Date> : T_9[K]; } : never)[], unknown>;
658
+ isError: false;
659
+ error: null;
660
+ isLoading: false;
661
+ isLoadingError: false;
662
+ isRefetchError: false;
663
+ isSuccess: true;
664
+ isPlaceholderData: true;
665
+ isFetchNextPageError: false;
666
+ isFetchPreviousPageError: false;
667
+ status: "success";
668
+ fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_10 ? { [K in keyof T_10]: Date extends T_10[K] ? string | Exclude<T_10[K], T_10[K] & Date> : T_10[K]; } : never)[], unknown>, Error>>;
669
+ fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<(T["$inferSelect"] extends infer T_10 ? { [K in keyof T_10]: Date extends T_10[K] ? string | Exclude<T_10[K], T_10[K] & Date> : T_10[K]; } : never)[], unknown>, Error>>;
670
+ hasNextPage: boolean;
671
+ hasPreviousPage: boolean;
672
+ isFetchingNextPage: boolean;
673
+ isFetchingPreviousPage: boolean;
674
+ dataUpdatedAt: number;
675
+ errorUpdatedAt: number;
676
+ failureCount: number;
677
+ failureReason: Error | null;
678
+ errorUpdateCount: number;
679
+ isFetched: boolean;
680
+ isFetchedAfterMount: boolean;
681
+ isFetching: boolean;
682
+ isInitialLoading: boolean;
683
+ isPaused: boolean;
684
+ isRefetching: boolean;
685
+ isStale: boolean;
686
+ isEnabled: boolean;
193
687
  fetchStatus: import("@tanstack/react-query").FetchStatus;
194
688
  };
195
689
  /** One row; `null` waits (nothing open yet). */
196
690
  useGet: (id: string | null) => import("@tanstack/react-query").UseQueryResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError>;
197
691
  useCreate: () => import("@tanstack/react-query").UseMutationResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError, NewRow<T>, unknown>;
198
- /** Shows the patch at once in every cached list and row. */
692
+ /**
693
+ * Shows the patch at once in every cached list, page and row under the
694
+ * table's key; a save that fails takes it back at once (and the mutation's
695
+ * error says why), then the table refetches.
696
+ */
199
697
  useUpdate: () => import("@tanstack/react-query").UseMutationResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError, {
200
698
  id: string;
201
699
  patch: Patch<T>;
202
- }, unknown>;
203
- /** Takes the row out of every cached list at once. */
204
- useDelete: () => import("@tanstack/react-query").UseMutationResult<void, EntityError, string, unknown>;
700
+ }, Before[]>;
701
+ /** Takes the row out of every cached list at once; a delete that fails puts it back. */
702
+ useDelete: () => import("@tanstack/react-query").UseMutationResult<void, EntityError, string, Before[]>;
205
703
  };
206
704
  export type TableClient<T extends Entity> = ReturnType<typeof tableClient<T>>;
207
705
  /** To the login page, and back here after. */
package/dist/client.js CHANGED
@@ -5,11 +5,12 @@
5
5
  // `app.table(companies)` reads and writes that table's rows through the
6
6
  // entities route; its hooks keep TanStack Query's cache in step, so an edit
7
7
  // shows at once and never snaps back. `app.contracts` asks the org's agents.
8
- import { keepPreviousData, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { hashKey, keepPreviousData, skipToken, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { useEffect, useMemo, useState } from "react";
9
10
  import { createInvoker } from "@shubh90/app-runtime/invoke";
10
- import { getTableName } from "drizzle-orm";
11
+ import { getTableColumns, getTableName } from "drizzle-orm";
11
12
  import { z } from "zod/v4";
12
- import { ENTITIES_PATH, EntityError } from "./entities.js";
13
+ import { ENTITIES_PATH, EntityError, LIST_LIMIT } from "./entities.js";
13
14
  export { EntityError, LIST_LIMIT } from "./entities.js";
14
15
  export { siteOf } from "./schema.js";
15
16
  export { imageSrc } from "./images.js";
@@ -19,12 +20,113 @@ export function createApp() {
19
20
  }
20
21
  /** Every list's rows before its first answer: one array, so they never change identity while pending. */
21
22
  const NO_ROWS = [];
23
+ /** Rows one page of `useInfiniteList` asks for; scrolling near the end asks for the next. */
24
+ export const PAGE_SIZE = 100;
25
+ /** How long after a list settles its sibling views' first pages are fetched: the page's own work goes first. */
26
+ const PREFETCH_AFTER_MS = 300;
27
+ /** A prefetched first page counts as fresh this long, so settling again doesn't fetch it again. */
28
+ const PREFETCH_FRESH_MS = 30_000;
29
+ /** A cached row as any query under a table's key holds it: an object with the row's id. */
30
+ const cachedRow = z.looseObject({ id: z.string() });
31
+ /** A cached useInfiniteList: its pages of rows. */
32
+ const cachedPages = z.looseObject({ pages: z.array(z.array(z.unknown())) });
33
+ const cachedSources = z.record(z.string(), z.unknown());
34
+ /** The rows a cached query holds: a list's, each of a useInfiniteList's pages, or useGet's one row. */
35
+ function rowsOf(data) {
36
+ if (Array.isArray(data))
37
+ return [data];
38
+ const paged = cachedPages.safeParse(data);
39
+ if (paged.success)
40
+ return paged.data.pages;
41
+ return cachedRow.safeParse(data).success ? [[data]] : [];
42
+ }
43
+ /** The same query with its rows replaced, page by page, in the shape it came in. */
44
+ function withRows(data, rows) {
45
+ if (Array.isArray(data))
46
+ return rows[0];
47
+ const paged = cachedPages.safeParse(data);
48
+ if (paged.success)
49
+ return { ...paged.data, pages: rows };
50
+ // One row (useGet): kept as it was when the change takes it out (an open panel's row stays while it closes).
51
+ const [[row = data] = []] = rows;
52
+ return row;
53
+ }
54
+ const isRow = (item, id) => {
55
+ const row = cachedRow.safeParse(item);
56
+ return row.success && row.data.id === id;
57
+ };
58
+ /**
59
+ * Every query under a table's key with one row changed (null takes it out):
60
+ * useList's, useInfiniteList's, useGet's, and a page's own server-function
61
+ * list keyed under the table, so a write shows everywhere at once. Returns
62
+ * where the row was, so a write that fails puts back just that row, never
63
+ * another write's change made since. A query holding anything else is left alone.
64
+ */
65
+ function changeRow(client, key, id, fields, change) {
66
+ const before = [];
67
+ for (const [queryKey, data] of client.getQueriesData({ queryKey: key })) {
68
+ const pages = rowsOf(data);
69
+ pages.forEach((rows, page) => {
70
+ const index = rows.findIndex((item) => isRow(item, id));
71
+ if (index >= 0)
72
+ before.push({ key: queryKey, page, index, row: rows[index], fields });
73
+ });
74
+ if (!pages.some((rows) => rows.some((item) => isRow(item, id))))
75
+ continue;
76
+ client.setQueryData(queryKey, withRows(data, pages.map((rows) => rows.flatMap((item) => {
77
+ const row = cachedRow.safeParse(item);
78
+ if (!row.success || row.data.id !== id)
79
+ return [item];
80
+ const next = change(row.data);
81
+ return next === null ? [] : [next];
82
+ }))));
83
+ }
84
+ return before;
85
+ }
86
+ /** The queries under a table's key that hold one row: the only ones a write to it touches (or cancels). */
87
+ function holding(client, key, id) {
88
+ return client
89
+ .getQueriesData({ queryKey: key })
90
+ .filter(([, data]) => rowsOf(data).some((rows) => rows.some((item) => isRow(item, id))))
91
+ .map(([queryKey]) => queryKey);
92
+ }
93
+ /** A failed write taken back: its fields of the row as they were (a delete: the row, where it was); everything else as it is now. */
94
+ function putBack(client, id, before) {
95
+ for (const { key, page, index, row, fields } of before) {
96
+ client.setQueryData(key, (data) => {
97
+ const pages = rowsOf(data);
98
+ const rows = pages[page];
99
+ if (rows === undefined)
100
+ return data;
101
+ const was = cachedRow.safeParse(row);
102
+ const there = rows.some((item) => isRow(item, id));
103
+ // An update's row gone meanwhile (deleted) stays gone; only a delete puts its row back.
104
+ if (!there && fields !== null)
105
+ return data;
106
+ const back = there
107
+ ? rows.map((item) => {
108
+ if (!isRow(item, id))
109
+ return item;
110
+ const now = cachedRow.safeParse(item);
111
+ if (fields === null || !now.success || !was.success)
112
+ return row;
113
+ // Each field as it was, and gone if it wasn't there (a slim list the patch added a column to).
114
+ const kept = Object.entries(now.data).filter(([field]) => !fields.includes(field));
115
+ const restored = fields.filter((field) => Object.hasOwn(was.data, field)).map((field) => [field, was.data[field]]);
116
+ return Object.fromEntries([...kept, ...restored]);
117
+ })
118
+ : [...rows.slice(0, index), row, ...rows.slice(index)];
119
+ return withRows(data, pages.map((each, at) => (at === page ? back : each)));
120
+ });
121
+ }
122
+ }
22
123
  /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
23
124
  function tableClient(entity) {
24
125
  const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
25
126
  const keys = {
26
127
  all: ["light", base],
27
128
  list: (options) => ["light", base, "list", options],
129
+ pages: (options) => ["light", base, "pages", options],
28
130
  get: (id) => ["light", base, "get", id],
29
131
  write: ["light", base, "write"]
30
132
  };
@@ -35,18 +137,41 @@ function tableClient(entity) {
35
137
  const remove = async (id) => {
36
138
  await call("DELETE", `${base}/${id}`);
37
139
  };
140
+ const columns = new Set(Object.keys(getTableColumns(entity)));
141
+ const isColumn = (column) => columns.has(column);
142
+ /** A header's sort as the server takes it. DataTable sorts only columns `canSort` allows, so any other is a bug. */
143
+ const sortBy = ({ column, desc }) => {
144
+ if (!isColumn(column))
145
+ throw new Error(`${getTableName(entity)} has no column ${column} to sort by.`);
146
+ return desc ? `-${column}` : column;
147
+ };
148
+ const pagesQuery = (options) => {
149
+ const { pageSize = PAGE_SIZE, ...rest } = options;
150
+ return {
151
+ queryKey: keys.pages(options),
152
+ queryFn: ({ pageParam }) => list({ ...rest, limit: pageSize, offset: pageParam }),
153
+ initialPageParam: 0,
154
+ // A full page may have more after it; a short one is the end (or one an optimistic delete shortened, until the write's refetch).
155
+ getNextPageParam: (last, all) => last.length < pageSize ? undefined : all.reduce((sum, page) => sum + page.length, 0)
156
+ };
157
+ };
38
158
  /**
39
159
  * After a write, refetch the table once the last write in flight settles.
40
- * Returning the refetch keeps the mutation pending until it lands, so an
41
- * edit never flickers back to its old value; a failed write is corrected
42
- * by the same refetch.
160
+ * A saved write stays pending until the refetch lands, so an edit never
161
+ * flickers back to its old value. A failed one is already taken back, so
162
+ * its error reaches the page (the toast saying so) without waiting.
43
163
  */
44
164
  const useSettle = () => {
45
165
  const client = useQueryClient();
46
- return async () => {
166
+ const refetch = async () => {
47
167
  if (client.isMutating({ mutationKey: keys.write }) === 1)
48
168
  await client.invalidateQueries({ queryKey: keys.all });
49
169
  };
170
+ return (_data, error) => {
171
+ if (error === null)
172
+ return refetch();
173
+ void refetch();
174
+ };
50
175
  };
51
176
  return {
52
177
  /**
@@ -69,9 +194,77 @@ function tableClient(entity) {
69
194
  useList: (options = {}) => {
70
195
  const query = useQuery({ queryKey: keys.list(options), queryFn: () => list(options) });
71
196
  const { data: rows = NO_ROWS } = query;
197
+ const view = {
198
+ rows,
199
+ isPending: query.isPending,
200
+ loadError: query.isLoadingError ? query.error : null,
201
+ refetch: query.refetch,
202
+ // Stopped at the server's limit (not one the page chose): DataTable says it shows only the first ones, never drops the rest silently.
203
+ hasMore: options.limit === undefined && rows.length >= LIST_LIMIT,
204
+ loadMore: null,
205
+ isLoadingMore: false,
206
+ moreFailed: false,
207
+ isSwitching: false,
208
+ serverSort: null
209
+ };
72
210
  // A spread reads every field, so the page re-renders on any of them (isFetching
73
211
  // too); a list page shows those states anyway, and it keeps the result plain.
74
- return { ...query, rows };
212
+ return { ...query, ...view };
213
+ },
214
+ /**
215
+ * A list of any size, a page at a time: `loadMore` (DataTable calls it as
216
+ * the end scrolls into view) fetches the next. New options (a tab, a
217
+ * filter, a search) keep the last rows on screen with `isSwitching` until
218
+ * theirs land, so a click never blanks the table. `prefetch` names the
219
+ * sibling views (the other tabs): their first pages load once this one
220
+ * settles, so switching to one is instant. Sorting is the server's, over
221
+ * every row; `sort` is where it starts.
222
+ *
223
+ * const list = app.table(leads).useInfiniteList({ where: { status: tab } }, { prefetch: TABS.map((t) => ({ where: { status: t } })) });
224
+ * <DataTable list={list} … />
225
+ */
226
+ useInfiniteList: (options = {}, { prefetch = [] } = {}) => {
227
+ const client = useQueryClient();
228
+ const [sorting, setSorting] = useState(null);
229
+ const asked = sorting === null ? options : { ...options, sort: sortBy(sorting) };
230
+ const query = useInfiniteQuery({ ...pagesQuery(asked), placeholderData: keepPreviousData });
231
+ // Pages are by offset: a row added above the fold mid-scroll shifts the next page by one, so a row can come twice. The first stays.
232
+ const rows = useMemo(() => (query.data === undefined ? NO_ROWS : firstOfEach(query.data.pages.flat(), (row) => row.id)), [query.data]);
233
+ const settled = query.isSuccess && !query.isPlaceholderData;
234
+ // The sibling views as this one is sorted, less this one; keyed by their hash, so a page passing a new array each render changes nothing.
235
+ const siblings = prefetch.map((sibling) => (asked.sort === undefined ? sibling : { ...sibling, sort: asked.sort })).filter((sibling) => hashKey([sibling]) !== hashKey([asked]));
236
+ const siblingsKey = hashKey(siblings);
237
+ useEffect(() => {
238
+ if (!settled)
239
+ return;
240
+ const later = setTimeout(() => {
241
+ for (const sibling of siblings)
242
+ void client.prefetchInfiniteQuery({ ...pagesQuery(sibling), staleTime: PREFETCH_FRESH_MS });
243
+ }, PREFETCH_AFTER_MS);
244
+ return () => clearTimeout(later);
245
+ // `siblingsKey` stands for `siblings`, by value.
246
+ }, [settled, siblingsKey, client]);
247
+ const view = {
248
+ rows,
249
+ isPending: query.isPending,
250
+ loadError: query.isLoadingError ? query.error : null,
251
+ refetch: query.refetch,
252
+ hasMore: query.hasNextPage,
253
+ loadMore: () => {
254
+ if (query.hasNextPage && !query.isFetchingNextPage && !query.isPlaceholderData)
255
+ void query.fetchNextPage();
256
+ },
257
+ isLoadingMore: query.isFetchingNextPage,
258
+ moreFailed: query.isFetchNextPageError,
259
+ isSwitching: query.isPlaceholderData,
260
+ serverSort: {
261
+ // A header's choice, or the page's own `sort` until one is clicked.
262
+ sorting: sorting === null ? sortingOf(options.sort) : sorting,
263
+ setSorting,
264
+ canSort: isColumn
265
+ }
266
+ };
267
+ return { ...query, ...view };
75
268
  },
76
269
  /** One row; `null` waits (nothing open yet). */
77
270
  useGet: (id) => useQuery({
@@ -88,7 +281,11 @@ function tableClient(entity) {
88
281
  onSettled: settle
89
282
  });
90
283
  },
91
- /** Shows the patch at once in every cached list and row. */
284
+ /**
285
+ * Shows the patch at once in every cached list, page and row under the
286
+ * table's key; a save that fails takes it back at once (and the mutation's
287
+ * error says why), then the table refetches.
288
+ */
92
289
  useUpdate: () => {
93
290
  const client = useQueryClient();
94
291
  const settle = useSettle();
@@ -96,22 +293,26 @@ function tableClient(entity) {
96
293
  mutationKey: keys.write,
97
294
  mutationFn: ({ id, patch }) => update(id, patch),
98
295
  onMutate: async ({ id, patch }) => {
99
- await client.cancelQueries({ queryKey: keys.all });
296
+ // Only the queries holding this row: a tab switching meanwhile keeps loading.
297
+ await Promise.all(holding(client, keys.all, id).map((queryKey) => client.cancelQueries({ queryKey, exact: true })));
100
298
  // A value set here loses its old source until the server answers with who set it; sources sent merge in.
101
- const patched = (row) => row.id === id
102
- ? {
103
- ...row,
104
- ...patch,
105
- field_sources: Object.assign(Object.fromEntries(Object.entries(row.field_sources).filter(([column]) => !(column in patch))), patch.field_sources)
106
- }
107
- : row;
108
- client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.map(patched));
109
- client.setQueryData(keys.get(id), (row) => row && patched(row));
299
+ // A row cached without its sources (a page's slim list) just takes the patch.
300
+ const patched = (row) => {
301
+ const sources = cachedSources.safeParse(row.field_sources);
302
+ if (!sources.success)
303
+ return { ...row, ...patch };
304
+ const kept = Object.fromEntries(Object.entries(sources.data).filter(([column]) => !(column in patch)));
305
+ return { ...row, ...patch, field_sources: Object.assign(kept, patch.field_sources) };
306
+ };
307
+ // Its sources change with its values (an edited value loses its old source), so they go back with them.
308
+ return changeRow(client, keys.all, id, [...Object.keys(patch), "field_sources"], patched);
110
309
  },
310
+ // No context when onMutate itself threw: nothing was changed to put back.
311
+ onError: (_error, { id }, before = []) => putBack(client, id, before),
111
312
  onSettled: settle
112
313
  });
113
314
  },
114
- /** Takes the row out of every cached list at once. */
315
+ /** Takes the row out of every cached list at once; a delete that fails puts it back. */
115
316
  useDelete: () => {
116
317
  const client = useQueryClient();
117
318
  const settle = useSettle();
@@ -119,14 +320,33 @@ function tableClient(entity) {
119
320
  mutationKey: keys.write,
120
321
  mutationFn: remove,
121
322
  onMutate: async (id) => {
122
- await client.cancelQueries({ queryKey: keys.all });
123
- client.setQueriesData({ queryKey: [...keys.all, "list"] }, (rows) => rows && rows.filter((row) => row.id !== id));
323
+ await Promise.all(holding(client, keys.all, id).map((queryKey) => client.cancelQueries({ queryKey, exact: true })));
324
+ return changeRow(client, keys.all, id, null, () => null);
124
325
  },
326
+ onError: (_error, id, before = []) => putBack(client, id, before),
125
327
  onSettled: settle
126
328
  });
127
329
  }
128
330
  };
129
331
  }
332
+ /** A page's `sort` ("name", "-created_at") as a header shows it. */
333
+ function sortingOf(sort) {
334
+ if (sort === undefined)
335
+ return null;
336
+ return sort.startsWith("-") ? { column: sort.slice(1), desc: true } : { column: sort, desc: false };
337
+ }
338
+ /** Each row once, where it first came. */
339
+ function firstOfEach(rows, idOf) {
340
+ const seen = new Set();
341
+ const once = [];
342
+ for (const row of rows) {
343
+ if (seen.has(idOf(row)))
344
+ continue;
345
+ seen.add(idOf(row));
346
+ once.push(row);
347
+ }
348
+ return once;
349
+ }
130
350
  function json(body) {
131
351
  return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
132
352
  }
@@ -1,6 +1,6 @@
1
1
  import type { Entity, PlatformColumn } from "./schema.js";
2
2
  export declare const ENTITIES_PATH = "/api/light/entities";
3
- /** The most rows one list returns; page past it with `offset`. */
3
+ /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
4
4
  export declare const LIST_LIMIT = 1000;
5
5
  /** A value as JSON carries it: a Date column arrives as its ISO string. */
6
6
  type Wire<T> = {
@@ -19,6 +19,8 @@ export type Where<R> = {
19
19
  export type Sort<R> = (keyof R & string) | `-${keyof R & string}`;
20
20
  export type ListOptions<R> = {
21
21
  where?: Where<R>;
22
+ /** Rows with every one of these words in some text column of their own, case-insensitively: the server searches, so a paged list finds rows not loaded yet. */
23
+ search?: string;
22
24
  sort?: Sort<R>;
23
25
  /** At most LIST_LIMIT, which is also the default. */
24
26
  limit?: number;
package/dist/entities.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const ENTITIES_PATH = "/api/light/entities";
2
- /** The most rows one list returns; page past it with `offset`. */
2
+ /** The most rows one list returns; page past it with `offset` (useInfiniteList does). */
3
3
  export const LIST_LIMIT = 1000;
4
4
  /** A refused or failed entities call, with the route's own reason. */
5
5
  export class EntityError extends Error {
@@ -2,7 +2,7 @@
2
2
  // on any entity table, each request checked against the table's own Zod
3
3
  // schema and the app's door. Each write names its person for the table's
4
4
  // trigger, which records the change in light_changes (src/db/audit.ts).
5
- import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull, sql } from "drizzle-orm";
5
+ import { and, asc, desc, eq, getTableColumns, getTableName, ilike, inArray, isNull, or, sql } from "drizzle-orm";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { createSchemaFactory } from "drizzle-zod";
8
8
  import { isSameOrigin } from "@shubh90/app-runtime/chat/server";
@@ -10,6 +10,10 @@ import { z } from "zod/v4";
10
10
  import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
11
11
  import { PLATFORM_COLUMNS, fieldSource, isEntity } from "../schema.js";
12
12
  import { env } from "./env.js";
13
+ /** Drizzle's text column types: what a search reads. */
14
+ const SEARCHED_TYPES = new Set(["PgText", "PgVarchar", "PgChar"]);
15
+ /** A search box's worth of words; anything longer is a mistake, not a search. */
16
+ const SEARCH_MAX = 200;
13
17
  /** Who writes when the app runs with no sign-in at all: a bare local dev server. */
14
18
  const LOCAL_DEVELOPER = { id: null, name: "Local developer" };
15
19
  // Postgres's integrity violations: the person's request, not a failure, told with Postgres's reason.
@@ -107,17 +111,23 @@ function tableRules(entity) {
107
111
  field_sources: sources
108
112
  })
109
113
  .strict();
114
+ // The columns a search reads: the table's own text columns (not the platform's, ids or JSON).
115
+ const searched = Object.entries(columns)
116
+ .filter(([name, c]) => !(name in PLATFORM_COLUMNS) && SEARCHED_TYPES.has(c.columnType))
117
+ .map(([, c]) => c);
110
118
  const match = Object.fromEntries(Object.entries(createSelectSchema(entity).shape).map(([key, value]) => [key, z.union([value, z.array(value)])]));
111
119
  return {
112
120
  name: getTableName(entity),
113
121
  entity,
114
122
  column,
115
123
  id: column("id"),
124
+ searched,
116
125
  create: writable(createInsertSchema(entity).shape),
117
126
  update: writable(createUpdateSchema(entity).shape),
118
127
  list: z
119
128
  .object({
120
129
  where: z.object(match).partial().strict().default({}),
130
+ search: z.string().trim().max(SEARCH_MAX).default(""),
121
131
  sort: z.literal([...names, ...names.map((name) => `-${name}`)]).default("-created_at"),
122
132
  limit: z.int().min(1).max(LIST_LIMIT).default(LIST_LIMIT),
123
133
  offset: z.int().min(0).default(0)
@@ -130,7 +140,7 @@ async function list(db, table, url) {
130
140
  const query = table.list.safeParse(q === null ? {} : readJson(q));
131
141
  if (!query.success)
132
142
  return refuse(400, "invalid", z.prettifyError(query.error));
133
- const { where, sort, limit, offset } = query.data;
143
+ const { where, search, sort, limit, offset } = query.data;
134
144
  const order = sort.startsWith("-") ? desc(table.column(sort.slice(1))) : asc(table.column(sort));
135
145
  const conditions = Object.entries(where).map(([name, value]) => {
136
146
  const column = table.column(name);
@@ -140,6 +150,11 @@ async function list(db, table, url) {
140
150
  return inArray(column, value);
141
151
  return eq(column, value);
142
152
  });
153
+ // Every word, each in some text column; a table with no text columns has nothing a search could match.
154
+ for (const word of search.split(/\s+/).filter(Boolean)) {
155
+ const pattern = `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
156
+ conditions.push(or(...table.searched.map((column) => ilike(column, pattern))) ?? sql `false`);
157
+ }
143
158
  const rows = await db
144
159
  .select()
145
160
  .from(table.entity)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -12,7 +12,6 @@
12
12
  "url": "https://github.com/shubh9/mii.git",
13
13
  "directory": "packages/app-sdk"
14
14
  },
15
- "packageManager": "pnpm@11.0.9",
16
15
  "files": [
17
16
  "dist",
18
17
  "bin"
@@ -42,14 +41,6 @@
42
41
  "default": "./dist/vite.js"
43
42
  }
44
43
  },
45
- "scripts": {
46
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
47
- "typecheck": "tsc --noEmit",
48
- "lint": "eslint src test --max-warnings 0",
49
- "test": "node --test --import tsx \"test/**/*.test.ts\"",
50
- "check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
51
- "prepack": "pnpm run build"
52
- },
53
44
  "peerDependencies": {
54
45
  "@tanstack/react-query": ">=5.69",
55
46
  "drizzle-orm": ">=0.45",
@@ -82,5 +73,12 @@
82
73
  "typescript-eslint": "^8.70.1",
83
74
  "vite": "^8.2.0",
84
75
  "zod": "^4.6.5"
76
+ },
77
+ "scripts": {
78
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
79
+ "typecheck": "tsc --noEmit",
80
+ "lint": "eslint src test --max-warnings 0",
81
+ "test": "node --test --import tsx \"test/**/*.test.ts\"",
82
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build"
85
83
  }
86
- }
84
+ }