@svgrid/grid 3.0.4 → 3.0.5

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 (77) hide show
  1. package/README.md +4 -4
  2. package/dist/SvGrid.controller.svelte.d.ts +12 -2
  3. package/dist/SvGrid.controller.svelte.js +192 -8
  4. package/dist/SvGrid.css +73 -0
  5. package/dist/SvGrid.svelte +128 -3
  6. package/dist/SvGrid.types.d.ts +356 -3
  7. package/dist/build-api.js +11 -6
  8. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CID62rlI.js} +11 -11
  9. package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-DLL-qZeu.js} +11 -11
  10. package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
  11. package/dist/cdn/{src-BKhZ6eXd.js → src-Bklofsad.js} +8818 -8798
  12. package/dist/cdn/{src-V1uu8iE9.js → src-jcFMkBnN.js} +4282 -4262
  13. package/dist/cdn/svgrid.js +27 -26
  14. package/dist/cdn/svgrid.svelte-external.js +27 -26
  15. package/dist/cdn/validate-C7Rn_Bns.js +76 -0
  16. package/dist/core.js +36 -13
  17. package/dist/editing.js +10 -0
  18. package/dist/gantt-view.svelte.d.ts +24 -0
  19. package/dist/gantt-view.svelte.js +13 -0
  20. package/dist/grid-icons.d.ts +2 -2
  21. package/dist/grid-icons.js +2 -2
  22. package/dist/grid-messages.d.ts +6 -0
  23. package/dist/grid-messages.js +5 -0
  24. package/dist/index.d.ts +5 -4
  25. package/dist/index.js +3 -3
  26. package/dist/row-model.d.ts +133 -0
  27. package/dist/row-model.js +16 -0
  28. package/dist/selection.d.ts +2 -1
  29. package/dist/selection.js +30 -2
  30. package/dist/server-block-cache.d.ts +214 -0
  31. package/dist/server-block-cache.js +531 -0
  32. package/dist/server-data-source.d.ts +240 -6
  33. package/dist/server-data-source.js +220 -20
  34. package/dist/server.d.ts +13 -0
  35. package/dist/server.js +13 -0
  36. package/dist/validate.d.ts +4 -0
  37. package/dist/validate.js +21 -3
  38. package/package.json +6 -1
  39. package/src/SvGrid.controller.svelte.ts +191 -8
  40. package/src/SvGrid.css +73 -0
  41. package/src/SvGrid.svelte +128 -3
  42. package/src/SvGrid.types.ts +376 -3
  43. package/src/build-api.ts +11 -4
  44. package/src/core.rowmodel-cache.test.ts +51 -0
  45. package/src/core.ts +33 -12
  46. package/src/editing.ts +8 -0
  47. package/src/gantt-stub.test.svelte +38 -0
  48. package/src/gantt-view.svelte.ts +35 -0
  49. package/src/grid-icons.ts +2 -2
  50. package/src/grid-messages.ts +12 -0
  51. package/src/icon-seam.test.ts +2 -4
  52. package/src/index.ts +36 -10
  53. package/src/row-model.ts +146 -0
  54. package/src/selection.test.ts +3 -0
  55. package/src/selection.ts +32 -2
  56. package/src/server-block-cache.test.ts +645 -0
  57. package/src/server-block-cache.ts +677 -0
  58. package/src/server-data-source.infinite.test.ts +343 -0
  59. package/src/server-data-source.ts +450 -27
  60. package/src/server.ts +47 -0
  61. package/src/svgrid.gantt-seam.test.ts +199 -0
  62. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  63. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  64. package/src/svgrid.upsell-license.test.ts +7 -4
  65. package/src/validate.test.ts +29 -0
  66. package/src/validate.ts +28 -3
  67. package/dist/SvGroupCell.svelte +0 -141
  68. package/dist/SvGroupCell.svelte.d.ts +0 -49
  69. package/dist/SvRowGroupPanel.svelte +0 -186
  70. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  71. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  72. package/dist/server-group-model.d.ts +0 -98
  73. package/dist/server-group-model.js +0 -263
  74. package/src/SvGroupCell.svelte +0 -141
  75. package/src/SvRowGroupPanel.svelte +0 -186
  76. package/src/server-group-model.test.ts +0 -294
  77. package/src/server-group-model.ts +0 -370
@@ -16,10 +16,17 @@
16
16
  * follow-up re-fetch of the current page lands.
17
17
  */
18
18
  import type { GridPredicateExpr } from './filtering/predicate-expr.js';
19
+ import { type BlockState } from './server-block-cache.js';
20
+ import { type GridFilterState, type GridRowModel } from './row-model.js';
21
+ /** Sort clauses in priority order; `id` is the column id. */
19
22
  export type ServerSortModel = Array<{
20
23
  id: string;
21
24
  desc: boolean;
22
25
  }>;
26
+ /**
27
+ * What a request carries for filtering: the global search, the per-column
28
+ * operator filters, and the advanced-filter expression.
29
+ */
23
30
  export type ServerFilterModel = {
24
31
  /** Free-text global search. */
25
32
  global?: string;
@@ -58,6 +65,11 @@ export type ServerAggregation = {
58
65
  col: string;
59
66
  fn: 'sum' | 'avg' | 'min' | 'max' | 'count';
60
67
  };
68
+ /**
69
+ * One range of rows as the grid asks for it. A flat request carries the
70
+ * range, the sort and the filter; a grouped request adds `groupBy`,
71
+ * `groupKeys` and `aggregations`; a pivoted one `pivotBy` and `pivotMode`.
72
+ */
61
73
  export type ServerRequest = {
62
74
  /** Zero-based index of the first row wanted (inclusive). */
63
75
  startRow: number;
@@ -82,6 +94,33 @@ export type ServerRequest = {
82
94
  groupKeys?: string[];
83
95
  /** Value columns to aggregate per group. */
84
96
  aggregations?: ServerAggregation[];
97
+ /**
98
+ * Server-side pivot: the columns whose distinct values become columns.
99
+ * With `pivotMode` on, a group row carries one value per (pivot key x
100
+ * aggregation) under a field named `<key>_<col>` (the separator is the
101
+ * row model's `pivotFieldSeparator`), and the response lists those
102
+ * fields in `pivotResultFields`. Only the Enterprise row model sets it.
103
+ */
104
+ pivotBy?: string[];
105
+ pivotMode?: boolean;
106
+ /**
107
+ * True on a top-level request when the grid wants a grand-total row and
108
+ * does not have one cached. Answer with `ServerResult.grandTotal`. Only
109
+ * the Enterprise server row model sets it.
110
+ */
111
+ needsGrandTotal?: boolean;
112
+ /**
113
+ * The group (or tree node) row being expanded, when this request is for
114
+ * its children. Handy for backends that key children off something on
115
+ * the parent rather than off `groupKeys`. Absent at the top level.
116
+ */
117
+ parentRow?: unknown;
118
+ /**
119
+ * Whatever the app passed as `context` to its controller, forwarded
120
+ * untouched on every request. Keep it JSON-serialisable: the SvelteKit
121
+ * transport posts the whole request to your endpoint.
122
+ */
123
+ context?: unknown;
85
124
  };
86
125
  /**
87
126
  * A group row in the server-side group/tree model - one distinct key at a
@@ -104,13 +143,28 @@ export type ServerGroupRow<TData> = {
104
143
  loading: boolean;
105
144
  /** Aggregate values keyed by column id, read from the group's response row. */
106
145
  aggregates: Record<string, unknown>;
146
+ /**
147
+ * How many rows this group holds, when the backend said (see the row
148
+ * model's `childCount` option). Drawn next to the key by `SvGroupCell`,
149
+ * and used to size the group's scrollbar before its first block lands.
150
+ */
151
+ childCount?: number;
152
+ /**
153
+ * `false` when nothing can open beneath this row: the innermost group
154
+ * level under a server-side pivot, whose rows are the result itself.
155
+ * The group cell then draws no expander.
156
+ */
157
+ expandable?: boolean;
107
158
  /** The raw response row for this group (key + aggregates), for cell rendering. */
108
159
  data: TData;
109
160
  };
161
+ /** A data row in the display list. */
110
162
  export type ServerLeafRow<TData> = {
111
163
  kind: 'leaf';
112
164
  id: string;
113
165
  level: number;
166
+ /** The group path this leaf sits under. Set by the block-cached row model. */
167
+ route?: string[];
114
168
  data: TData;
115
169
  };
116
170
  /**
@@ -150,11 +204,50 @@ export type ServerSkeletonRow = {
150
204
  id: string;
151
205
  level: number;
152
206
  };
153
- /** A row in the flattened server-side group/tree display list. */
154
- export type ServerDisplayRow<TData> = ServerGroupRow<TData> | ServerLeafRow<TData> | ServerMoreRow | ServerFooterRow<TData> | ServerSkeletonRow;
207
+ /**
208
+ * A grand-total row across the whole result. One per grid, at the top or
209
+ * the bottom, with the fixed id `sv-grand-total` so a transaction can
210
+ * address it.
211
+ */
212
+ export type ServerGrandTotalRow<TData> = {
213
+ kind: 'grandTotal';
214
+ id: 'sv-grand-total';
215
+ level: 0;
216
+ aggregates: Record<string, unknown>;
217
+ data: TData;
218
+ };
219
+ /**
220
+ * A row whose data has not arrived: `loading` while its block is in
221
+ * flight, `failed` when the fetch rejected. Shared, frozen objects - one
222
+ * per state, not one per row - so a million unloaded rows cost nothing to
223
+ * represent. They also carry the grid's placeholder mark, so
224
+ * `rowPlaceholderState()` recognises them and the grid draws them itself.
225
+ */
226
+ export type ServerPlaceholderRow = {
227
+ readonly kind: 'placeholder';
228
+ readonly state: 'loading' | 'failed';
229
+ };
230
+ /**
231
+ * Every row a server row model can put on screen. Each carries an `id` and
232
+ * a `level`. The block-cached model adds {@link ServerPlaceholderRow} for
233
+ * rows not yet loaded - see its own `ServerRowModelDisplayRow`.
234
+ */
235
+ export type ServerDisplayRow<TData> = ServerGroupRow<TData> | ServerLeafRow<TData> | ServerMoreRow | ServerFooterRow<TData> | ServerSkeletonRow | ServerGrandTotalRow<TData>;
236
+ /**
237
+ * What `getRows` answers with: the rows for the requested range and the
238
+ * count after filtering, plus the grand total and the pivot fields when
239
+ * asked for.
240
+ */
155
241
  export type ServerResult<TData> = {
156
242
  rows: ReadonlyArray<TData>;
157
- /** Total row count after filtering (for the pager). */
243
+ /**
244
+ * Total row count after filtering, for the pager and the scrollbar.
245
+ *
246
+ * Paging needs it. Infinite scrolling does not: send `-1` (or, from a
247
+ * source typed loosely, omit it) when counting is expensive, and the grid
248
+ * discovers the end from the first short block instead. See
249
+ * `mode: 'infinite'` on {@link ServerControllerOptions}.
250
+ */
158
251
  rowCount: number;
159
252
  /**
160
253
  * Set `true` ONLY when `filterModel.expression` was applied in full. Leave it
@@ -162,7 +255,53 @@ export type ServerResult<TData> = {
162
255
  * pretending the filter ran. See the contract on `ServerFilterModel.expression`.
163
256
  */
164
257
  appliedExpression?: boolean;
258
+ /**
259
+ * The grand-total row, in reply to `ServerRequest.needsGrandTotal`: an
260
+ * object sets it, `null` removes it, and leaving it out keeps whatever the
261
+ * grid already had. Shaped like a group row: the aggregate values live
262
+ * under their column ids.
263
+ */
264
+ grandTotal?: TData | null;
265
+ /**
266
+ * Pivot mode: the fields the group rows carry for the pivoted values,
267
+ * e.g. `["2024_amount", "2025_amount"]`, from which the grid builds one
268
+ * column per field under a header group per pivot key. Send the full
269
+ * list on every pivoted response; the grid keeps the union. Every group
270
+ * row carries every listed field, `null` where no rows fall in that
271
+ * cell.
272
+ */
273
+ pivotResultFields?: string[];
274
+ /**
275
+ * Pivot mode, the long way: full column definitions instead of field
276
+ * names, for a backend that wants to name and format them itself. Wins
277
+ * over `pivotResultFields` when both are present.
278
+ */
279
+ pivotResultColumns?: ReadonlyArray<unknown>;
165
280
  };
281
+ /**
282
+ * A selection expressed as a rule rather than a list, for `updateWhere`.
283
+ * The flat shape is "these ids" or "everything except these"; the nested
284
+ * shape is the same idea per group, keyed by group key or leaf id, as the
285
+ * Enterprise row model keeps it under `groupSelects: 'descendants'`.
286
+ */
287
+ export type ServerSelectionRule = {
288
+ selectAll: boolean;
289
+ toggled: string[];
290
+ } | {
291
+ selectAllChildren: boolean;
292
+ toggled: Record<string, {
293
+ selectAllChildren: boolean;
294
+ toggled: Record<string, unknown>;
295
+ group?: boolean;
296
+ }>;
297
+ group?: boolean;
298
+ /** The group columns the tree's levels are keyed by, outer to inner. */
299
+ groupBy?: string[];
300
+ };
301
+ /**
302
+ * The contract a backend implements: `getRows`, and optionally the write
303
+ * methods, a bulk edit by rule and `destroy`.
304
+ */
166
305
  export type ServerDataSource<TData> = {
167
306
  getRows(request: ServerRequest): Promise<ServerResult<TData>>;
168
307
  /**
@@ -174,7 +313,28 @@ export type ServerDataSource<TData> = {
174
313
  createRow?(input: Partial<TData>): Promise<TData>;
175
314
  updateRow?(id: string, patch: Partial<TData>): Promise<TData>;
176
315
  deleteRow?(id: string): Promise<void>;
316
+ /**
317
+ * Apply one patch to every row a selection RULE names, server-side - the
318
+ * write behind a bulk edit of rows the grid never loaded. `filterModel`
319
+ * scopes the rows exactly as `getRows` does; `selection` is the rule:
320
+ * either "these ids" (`selectAll: false`) or "everything but these"
321
+ * (`selectAll: true`), or the per-group tree the Enterprise row model
322
+ * keeps under `groupSelects: 'descendants'`. Resolve with how many rows
323
+ * changed. Optional: without it, a bulk edit under select-all is refused
324
+ * rather than silently applied to the loaded rows only.
325
+ */
326
+ updateWhere?(filterModel: ServerFilterModel, patch: Partial<TData>, selection: ServerSelectionRule): Promise<number>;
327
+ /**
328
+ * Called once, from the controller's `dispose()`, for a source with
329
+ * something to close: a socket, a subscription, a worker.
330
+ */
331
+ destroy?(): void;
177
332
  };
333
+ /**
334
+ * What `createServerDataSource` emits on every change: the rows on hand,
335
+ * the counts, the loading and saving flags, the last error, and the
336
+ * current sort and filter.
337
+ */
178
338
  export type ServerState<TData> = {
179
339
  rows: ReadonlyArray<TData>;
180
340
  total: number;
@@ -196,12 +356,50 @@ export type ServerState<TData> = {
196
356
  * compiling; the controller always sets it.
197
357
  */
198
358
  expressionUnapplied?: boolean;
359
+ /**
360
+ * Rows after filtering, or `null` when the backend has not said and the
361
+ * end has not been found yet. Only ever null in `infinite` mode; `total`
362
+ * carries the best current guess either way.
363
+ */
364
+ rowCount?: number | null;
365
+ /** False while the end of an infinite list is still being discovered. */
366
+ lastRowKnown?: boolean;
367
+ /** Blocks whose fetch failed, in `infinite` mode. Empty when all is well. */
368
+ failedBlocks?: number[];
199
369
  };
200
- export type ServerController<TData> = {
370
+ /**
371
+ * Everything a controller can do, plus the {@link GridRowModel} surface, so
372
+ * one object can be driven by hand OR handed to `<SvGrid rowModel>` and wire
373
+ * itself up.
374
+ */
375
+ export type ServerController<TData> = GridRowModel<TData> & {
201
376
  /** Re-fetch the current page (e.g. after a mutation). */
202
377
  refresh(): void;
378
+ /**
379
+ * The rows on screen, so `infinite` mode knows which blocks to fetch and
380
+ * which to spare from eviction. Wire it to the grid's
381
+ * `onVisibleRangeChange` - or pass the controller as `rowModel` and the
382
+ * grid wires it for you. No-op in `page` mode.
383
+ */
384
+ setViewport(startIndex: number, endIndex: number): void;
385
+ /** Re-fetch the blocks that failed. No-op in `page` mode. */
386
+ retryLoads(): void;
387
+ /**
388
+ * Throw away every cached block and reload from the current viewport.
389
+ * `refresh()` is the gentler option: it keeps the row count and the scroll
390
+ * position. No-op in `page` mode, where there is only ever one page held.
391
+ */
392
+ purge(): void;
393
+ /** Cached blocks and their state, for logging and tests. Empty in `page` mode. */
394
+ getCacheState(): BlockState[];
203
395
  setSort(sortModel: ServerSortModel): void;
204
- setFilter(filterModel: ServerFilterModel): void;
396
+ /**
397
+ * Replace the filter. Takes the `ServerFilterModel` a request carries, or
398
+ * the payload the grid's own `onFiltersChange` hands you - which is a
399
+ * list of columns rather than a map, and is converted here so that every
400
+ * app does not write the same `Object.fromEntries` by hand.
401
+ */
402
+ setFilter(filterModel: ServerFilterModel | GridFilterState): void;
205
403
  setPage(pageIndex: number): void;
206
404
  setPageSize(pageSize: number): void;
207
405
  /**
@@ -225,10 +423,14 @@ export type ServerController<TData> = {
225
423
  /** Stop accepting in-flight responses (call on unmount). */
226
424
  dispose(): void;
227
425
  };
426
+ /**
427
+ * Options for `createServerDataSource`: the page size, or the block
428
+ * settings in `infinite` mode, optimistic writes and the change callback.
429
+ */
228
430
  export type ServerControllerOptions<TData> = {
229
431
  pageSize?: number;
230
432
  /** Called whenever any of `rows` / `total` / `loading` / page changes. */
231
- onChange: (state: ServerState<TData>) => void;
433
+ onChange?: (state: ServerState<TData>) => void;
232
434
  /**
233
435
  * Apply `updateRow` / `deleteRow` to the local rows immediately (before the
234
436
  * server confirms) and roll back on error - so edits feel instant and no
@@ -238,5 +440,37 @@ export type ServerControllerOptions<TData> = {
238
440
  optimistic?: boolean;
239
441
  /** Resolve a row's stable id, so optimistic update/delete can find it in `rows`. */
240
442
  getRowId?: (row: TData) => string;
443
+ /**
444
+ * How rows reach the grid.
445
+ *
446
+ * - `page` (default): one page at a time. `state.rows` is that page, and
447
+ * `setPage` moves between them.
448
+ * - `infinite`: one long scrollable list. `state.rows` spans the whole
449
+ * result, with placeholder rows standing in for blocks nobody has
450
+ * scrolled to yet; blocks load as the viewport reaches them.
451
+ */
452
+ mode?: 'page' | 'infinite';
453
+ /** `infinite` mode: rows per request. Default 100. */
454
+ blockSize?: number;
455
+ /**
456
+ * `infinite` mode: keep at most this many loaded blocks, evicting the
457
+ * least recently seen. Unlimited by default.
458
+ */
459
+ maxBlocksInCache?: number;
460
+ /** `infinite` mode: requests open at once. Default 2. */
461
+ maxConcurrentRequests?: number;
462
+ /** `infinite` mode: wait for the scroll to settle this long before fetching. */
463
+ blockLoadDebounceMs?: number;
464
+ /**
465
+ * `infinite` mode: rows to claim before anything has loaded, so there is a
466
+ * scrollbar on first paint. Default 1.
467
+ */
468
+ initialRowCount?: number;
241
469
  };
470
+ /**
471
+ * The free server row model over a `ServerDataSource`: one page at a time,
472
+ * or one block-cached list in `infinite` mode, with sort, filter, race
473
+ * safety and writes. The result is a `GridRowModel`, so
474
+ * `<SvGrid rowModel={ctl} />` wires every seam.
475
+ */
242
476
  export declare function createServerDataSource<TData>(source: ServerDataSource<TData>, options: ServerControllerOptions<TData>): ServerController<TData>;
@@ -1,3 +1,11 @@
1
+ import { createBlockCache, rowPlaceholderState, } from './server-block-cache.js';
2
+ import { toServerFilterColumns } from './row-model.js';
3
+ /**
4
+ * The free server row model over a `ServerDataSource`: one page at a time,
5
+ * or one block-cached list in `infinite` mode, with sort, filter, race
6
+ * safety and writes. The result is a `GridRowModel`, so
7
+ * `<SvGrid rowModel={ctl} />` wires every seam.
8
+ */
1
9
  export function createServerDataSource(source, options) {
2
10
  const state = {
3
11
  rows: [],
@@ -12,6 +20,13 @@ export function createServerDataSource(source, options) {
12
20
  filterModel: {},
13
21
  expressionUnapplied: false,
14
22
  };
23
+ const infinite = options.mode === 'infinite';
24
+ // `onChange` is the one-callback API this controller shipped with;
25
+ // `subscribe` is the many-listener one `GridRowModel` needs. Both fire
26
+ // from `emit`, so a grid driven by `rowModel` and an app reading
27
+ // `onChange` stay in step.
28
+ const subscribers = new Set();
29
+ let cache = null;
15
30
  // Monotonic request id: only the latest fetch is allowed to land, so a slow
16
31
  // response for an old sort/filter can't clobber a newer one.
17
32
  let requestSeq = 0;
@@ -21,8 +36,87 @@ export function createServerDataSource(source, options) {
21
36
  let warnedExpressionUnapplied = false;
22
37
  const emit = () => {
23
38
  state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize));
24
- options.onChange({ ...state });
39
+ options.onChange?.({ ...state });
40
+ for (const notify of subscribers)
41
+ notify();
25
42
  };
43
+ /**
44
+ * Pull the block cache's view of the world into `state` and emit.
45
+ *
46
+ * `total` stays a plain number because the pager and the footer have
47
+ * always read it as one; while the end is undiscovered it holds the
48
+ * current optimistic length, which is exactly what the scrollbar needs.
49
+ * `rowCount` is the honest answer, null and all.
50
+ */
51
+ function emitFromCache() {
52
+ if (!cache || disposed)
53
+ return;
54
+ const rows = cache.rows();
55
+ state.rows = rows;
56
+ state.rowCount = cache.rowCount();
57
+ state.lastRowKnown = cache.lastRowKnown();
58
+ state.total = cache.rowCount() ?? rows.length;
59
+ emit();
60
+ }
61
+ function buildCache() {
62
+ return createBlockCache({
63
+ blockSize: options.blockSize ?? 100,
64
+ maxBlocksInCache: options.maxBlocksInCache,
65
+ maxConcurrentRequests: options.maxConcurrentRequests,
66
+ blockLoadDebounceMs: options.blockLoadDebounceMs,
67
+ initialRowCount: options.initialRowCount,
68
+ fetch: async (startRow, endRow) => {
69
+ const result = await source.getRows({
70
+ startRow,
71
+ endRow,
72
+ // A block is a page of its own size, so a backend that only knows
73
+ // how to page still works unchanged.
74
+ pageIndex: Math.floor(startRow / Math.max(1, endRow - startRow)),
75
+ pageSize: endRow - startRow,
76
+ sortModel: state.sortModel,
77
+ filterModel: state.filterModel,
78
+ groupBy: [],
79
+ groupKeys: [],
80
+ aggregations: [],
81
+ });
82
+ noteExpressionApplied(result);
83
+ return { rows: result.rows, rowCount: result.rowCount };
84
+ },
85
+ onChange: (s) => {
86
+ state.loading = s.loading;
87
+ state.failedBlocks = s.failedBlocks;
88
+ emitFromCache();
89
+ },
90
+ });
91
+ }
92
+ /** Reload from scratch: new sort, new filter, or an explicit purge. */
93
+ function resetCache() {
94
+ if (disposed)
95
+ return;
96
+ cache?.dispose();
97
+ cache = buildCache();
98
+ cache.setViewport(viewStart, viewEnd);
99
+ emitFromCache();
100
+ }
101
+ /**
102
+ * An expression was sent but the backend did not acknowledge applying it,
103
+ * so these rows are a SUPERSET of what was asked for. Say so instead of
104
+ * filtering here: filtering one page would turn "3 of 1,000,000 match"
105
+ * into a confident lie and make paging incoherent, since the next page
106
+ * would re-filter a different slice.
107
+ */
108
+ function noteExpressionApplied(result) {
109
+ state.expressionUnapplied =
110
+ state.filterModel.expression != null && result.appliedExpression !== true;
111
+ if (!state.expressionUnapplied || warnedExpressionUnapplied)
112
+ return;
113
+ warnedExpressionUnapplied = true;
114
+ console.warn('[svgrid] The data source was sent filterModel.expression but did not ' +
115
+ 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
116
+ 'and the rows shown are unfiltered. Apply the whole expression and ' +
117
+ 'acknowledge it, or clear the advanced filter. ' +
118
+ 'See https://svgrid.com/docs/help/server/server-filtering');
119
+ }
26
120
  async function fetchPage() {
27
121
  if (disposed)
28
122
  return;
@@ -39,7 +133,8 @@ export function createServerDataSource(source, options) {
39
133
  pageSize: state.pageSize,
40
134
  sortModel: state.sortModel,
41
135
  filterModel: state.filterModel,
42
- // Flat mode: no grouping. Group mode lives in createServerGroupModel.
136
+ // Flat mode: no grouping. Server-side grouping and tree data live in
137
+ // `createServerGroupModel` from @svgrid/enterprise, which fills these in.
43
138
  groupBy: [],
44
139
  groupKeys: [],
45
140
  aggregations: [],
@@ -48,21 +143,9 @@ export function createServerDataSource(source, options) {
48
143
  return; // stale
49
144
  state.rows = result.rows;
50
145
  state.total = result.rowCount;
51
- // An expression was sent but the backend did not acknowledge applying it,
52
- // so these rows are a SUPERSET of what was asked for. Say so instead of
53
- // filtering the loaded page here: filtering one page would turn
54
- // "3 of 1,000,000 match" into a confident lie and make paging incoherent,
55
- // since page 2 would re-filter a different slice.
56
- state.expressionUnapplied =
57
- state.filterModel.expression != null && result.appliedExpression !== true;
58
- if (state.expressionUnapplied && !warnedExpressionUnapplied) {
59
- warnedExpressionUnapplied = true;
60
- console.warn('[svgrid] The data source was sent filterModel.expression but did not ' +
61
- 'return `appliedExpression: true`, so the advanced filter is NOT applied ' +
62
- 'and the rows shown are unfiltered. Apply the whole expression and ' +
63
- 'acknowledge it, or clear the advanced filter. ' +
64
- 'See https://svgrid.com/docs/help/server/server-filtering');
65
- }
146
+ state.rowCount = result.rowCount;
147
+ state.lastRowKnown = true;
148
+ noteExpressionApplied(result);
66
149
  state.loading = false;
67
150
  emit();
68
151
  }
@@ -90,7 +173,12 @@ export function createServerDataSource(source, options) {
90
173
  return (async () => {
91
174
  try {
92
175
  const result = await thunk();
93
- await fetchPage();
176
+ // Page mode re-reads the page. Infinite mode re-reads the blocks it
177
+ // holds, in place: the list keeps its length and its scroll.
178
+ if (infinite)
179
+ cache?.refresh();
180
+ else
181
+ await fetchPage();
94
182
  return result;
95
183
  }
96
184
  finally {
@@ -101,12 +189,40 @@ export function createServerDataSource(source, options) {
101
189
  }
102
190
  const optimistic = !!options.optimistic && !!options.getRowId;
103
191
  const getRowId = options.getRowId;
192
+ // The last range the grid reported, so a sort / filter / purge can
193
+ // re-request what the user is actually looking at rather than row 0.
194
+ let viewStart = 0;
195
+ let viewEnd = 0;
104
196
  // Optimistic update: patch the local row, reconcile with the server result,
105
197
  // roll back on error. Falls back to the plain refresh path when the row
106
198
  // isn't on the current page (nothing local to update).
107
199
  async function optimisticUpdate(id, patch, fn) {
108
200
  if (disposed)
109
201
  throw new Error('createServerDataSource: controller is disposed');
202
+ if (infinite && cache) {
203
+ // The cache owns the rows here; patch it, not a copy the next block
204
+ // to land would overwrite.
205
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId(r) === id);
206
+ if (at < 0)
207
+ return mutate('updateRow', () => fn(id, patch));
208
+ const prev = cache.getRow(at);
209
+ cache.patch(at, { ...prev, ...patch });
210
+ state.saving = true;
211
+ emit();
212
+ try {
213
+ const result = await fn(id, patch);
214
+ cache.patch(at, result);
215
+ return result;
216
+ }
217
+ catch (err) {
218
+ cache.patch(at, prev);
219
+ throw err;
220
+ }
221
+ finally {
222
+ state.saving = false;
223
+ emit();
224
+ }
225
+ }
110
226
  const prevRows = state.rows;
111
227
  const idx = prevRows.findIndex((r) => getRowId(r) === id);
112
228
  if (idx < 0)
@@ -132,6 +248,27 @@ export function createServerDataSource(source, options) {
132
248
  async function optimisticDelete(id, fn) {
133
249
  if (disposed)
134
250
  throw new Error('createServerDataSource: controller is disposed');
251
+ if (infinite && cache) {
252
+ const at = cache.findIndex((r) => !rowPlaceholderState(r) && getRowId(r) === id);
253
+ if (at < 0)
254
+ return mutate('deleteRow', () => fn(id));
255
+ const prev = cache.getRow(at);
256
+ cache.remove(at, 1);
257
+ state.saving = true;
258
+ emit();
259
+ try {
260
+ await fn(id);
261
+ }
262
+ catch (err) {
263
+ cache.insert(at, [prev]);
264
+ throw err;
265
+ }
266
+ finally {
267
+ state.saving = false;
268
+ emit();
269
+ }
270
+ return;
271
+ }
135
272
  const prevRows = state.rows;
136
273
  const prevTotal = state.total;
137
274
  const next = prevRows.filter((r) => getRowId(r) !== id);
@@ -154,8 +291,54 @@ export function createServerDataSource(source, options) {
154
291
  emit();
155
292
  }
156
293
  }
294
+ // In infinite mode the first blocks are requested as soon as the grid
295
+ // reports a viewport; `refresh()` is what arms the cache before that.
296
+ if (infinite)
297
+ cache = buildCache();
157
298
  return {
158
- refresh: fetchPage,
299
+ refresh: () => {
300
+ if (!infinite)
301
+ return void fetchPage();
302
+ // Keep the count and the scroll position, re-request what is held.
303
+ cache?.refresh();
304
+ },
305
+ setViewport(startIndex, endIndex) {
306
+ viewStart = startIndex;
307
+ viewEnd = endIndex;
308
+ cache?.setViewport(startIndex, endIndex);
309
+ },
310
+ retryLoads: () => cache?.retryFailed(),
311
+ purge: () => cache?.purge(),
312
+ getCacheState: () => cache?.getCacheState() ?? [],
313
+ // --- GridRowModel -----------------------------------------------
314
+ subscribe(onChange) {
315
+ subscribers.add(onChange);
316
+ return () => {
317
+ subscribers.delete(onChange);
318
+ };
319
+ },
320
+ getRows: () => state.rows,
321
+ isLoading: () => state.loading,
322
+ getRowId: options.getRowId ? (row) => options.getRowId(row) : undefined,
323
+ // Only infinite mode has unloaded rows to stand in for.
324
+ rowPlaceholder: infinite ? (row) => rowPlaceholderState(row) : undefined,
325
+ retryRow: infinite ? () => cache?.retryFailed() : undefined,
326
+ /**
327
+ * A getter, not a snapshot: the grid re-reads this after every change
328
+ * notification, and a frozen object would leave the pager on page 1
329
+ * forever. Absent in infinite mode, where there are no pages.
330
+ */
331
+ get pagination() {
332
+ if (infinite)
333
+ return undefined;
334
+ return {
335
+ pageIndex: state.pageIndex,
336
+ pageSize: state.pageSize,
337
+ rowCount: state.total,
338
+ setPage: (pageIndex) => this.setPage(pageIndex),
339
+ setPageSize: (pageSize) => this.setPageSize(pageSize),
340
+ };
341
+ },
159
342
  createRow: (input) => mutate('createRow', source.createRow ? () => source.createRow(input) : null),
160
343
  updateRow: (id, patch) => {
161
344
  if (!source.updateRow)
@@ -172,14 +355,27 @@ export function createServerDataSource(source, options) {
172
355
  setSort(sortModel) {
173
356
  state.sortModel = sortModel;
174
357
  state.pageIndex = 0;
358
+ if (infinite)
359
+ return resetCache();
175
360
  void fetchPage();
176
361
  },
177
362
  setFilter(filterModel) {
178
- state.filterModel = filterModel;
363
+ state.filterModel = Array.isArray(filterModel.columns)
364
+ ? {
365
+ global: filterModel.global,
366
+ columns: toServerFilterColumns(filterModel),
367
+ }
368
+ : filterModel;
179
369
  state.pageIndex = 0;
370
+ if (infinite)
371
+ return resetCache();
180
372
  void fetchPage();
181
373
  },
182
374
  setPage(pageIndex) {
375
+ // There are no pages to move between when the whole result is one
376
+ // scrollable list; the grid scrolls instead.
377
+ if (infinite)
378
+ return;
183
379
  const clamped = Math.max(0, pageIndex);
184
380
  if (clamped === state.pageIndex)
185
381
  return;
@@ -189,11 +385,15 @@ export function createServerDataSource(source, options) {
189
385
  setPageSize(pageSize) {
190
386
  state.pageSize = Math.max(1, pageSize);
191
387
  state.pageIndex = 0;
388
+ if (infinite)
389
+ return;
192
390
  void fetchPage();
193
391
  },
194
392
  getState: () => ({ ...state }),
195
393
  dispose() {
196
394
  disposed = true;
395
+ cache?.dispose();
396
+ cache = null;
197
397
  // An in-flight fetch's resolution short-circuits on `disposed`, so it
198
398
  // never clears `loading`. Clear it here (and emit) so a disposed
199
399
  // controller doesn't report a permanent loading state.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@svgrid/grid/server` - the server-side data primitives, with no Svelte in
3
+ * the import graph.
4
+ *
5
+ * Everything here is also on the package root. This entry point exists for
6
+ * code that must not pull `SvGrid.svelte` into its bundle or its test
7
+ * runner: a SvelteKit `+server.ts` shaping a `ServerRequest` into a query,
8
+ * a worker feeding a block cache, or `@svgrid/enterprise`'s own row model,
9
+ * whose unit tests run without the Svelte compiler.
10
+ */
11
+ export { createServerDataSource, type ServerDataSource, type ServerSelectionRule, type ServerRequest, type ServerResult, type ServerController, type ServerControllerOptions, type ServerState, type ServerSortModel, type ServerFilterModel, type ServerAggregation, type ServerGroupRow, type ServerLeafRow, type ServerMoreRow, type ServerFooterRow, type ServerSkeletonRow, type ServerGrandTotalRow, type ServerPlaceholderRow, type ServerDisplayRow, } from './server-data-source.js';
12
+ export { createBlockCache, createRowPlaceholder, rowPlaceholderState, type BlockCache, type BlockCacheOptions, type BlockCacheState, type BlockFetchResult, type BlockState, } from './server-block-cache.js';
13
+ export { toServerFilterColumns, type GridRowModel, type GridFilterState, type GridRowModelSort, } from './row-model.js';