@svgrid/enterprise 3.1.0 → 3.1.2

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 (78) hide show
  1. package/dist/SvSheet.svelte +93 -8
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +9339 -9077
  3. package/dist/designer/assets/{GridMenus-8gTNssgV.js → GridMenus-DGZJKFvX.js} +1 -1
  4. package/dist/designer/assets/SvGrid.helpers-DE5mmORW.js +1 -0
  5. package/dist/designer/assets/{SvGridCellEditor-DMoWJjEI.js → SvGridCellEditor-C7NWcnNC.js} +1 -1
  6. package/dist/designer/assets/{SvGridChart-Cy1ATrWh.js → SvGridChart-Fg43Yi0M.js} +2 -2
  7. package/dist/designer/assets/{SvGridChartBuilder-eQ3mn0L9.js → SvGridChartBuilder-BqGuA3yU.js} +1 -1
  8. package/dist/designer/assets/{SvGridChartPanel-CATdVc9X.js → SvGridChartPanel-BsiXRAnx.js} +3 -3
  9. package/dist/designer/assets/{SvGridChartView-CmsZtCED.js → SvGridChartView-BgttK0tR.js} +1 -1
  10. package/dist/designer/assets/{SvListBox-BDS66lmZ.js → SvListBox-v2jm0-vf.js} +1 -1
  11. package/dist/designer/assets/cell-formatting-BWrW_O0n.js +1 -0
  12. package/dist/designer/assets/{chart-Cn7MnNqA.js → chart-BIl-dIRv.js} +1 -1
  13. package/dist/designer/assets/{chart-panel-messages-Dz7PTIqT.js → chart-panel-messages-4cF2vw7L.js} +1 -1
  14. package/dist/designer/assets/{chart-summary-BD1qYB2R.js → chart-summary-B7bTtSCC.js} +1 -1
  15. package/dist/designer/assets/{chart-trend-B8xr5LuD.js → chart-trend-CLsn5Hgd.js} +1 -1
  16. package/dist/designer/assets/export-format-DGscOjS9.js +6 -0
  17. package/dist/designer/assets/{index-BSjv6Qec.js → index-V_cMXdxt.js} +314 -314
  18. package/dist/designer/assets/index-l21iGkpF.css +1 -0
  19. package/dist/designer/assets/src-Mu7AGqT8.js +58 -0
  20. package/dist/designer/assets/validate-YxSrBF1H.js +1 -0
  21. package/dist/designer/index.html +12 -12
  22. package/dist/export-ooxml.d.ts +10 -0
  23. package/dist/export-ooxml.js +22 -5
  24. package/dist/import.js +11 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/node/studio.js +236 -132
  28. package/dist/server/index.d.ts +1 -0
  29. package/dist/server/index.js +1 -0
  30. package/dist/server/server-row-model.d.ts +9 -0
  31. package/dist/server/server-row-model.js +120 -3
  32. package/dist/server/worker-data-source.d.ts +62 -0
  33. package/dist/server/worker-data-source.js +133 -0
  34. package/dist/sheet/objects.d.ts +47 -0
  35. package/dist/sheet/objects.js +33 -0
  36. package/dist/studio/cli.js +55 -1
  37. package/dist/studio/emit-project.js +8 -3
  38. package/dist/studio/init-flow.d.ts +6 -1
  39. package/dist/studio/init-flow.js +43 -2
  40. package/dist/studio/scaffold-app.d.ts +6 -0
  41. package/dist/studio/scaffold-app.js +5 -3
  42. package/dist/studio/scaffold.js +29 -12
  43. package/dist/sveltekit/in-memory.d.ts +21 -1
  44. package/dist/sveltekit/in-memory.js +153 -105
  45. package/dist/sveltekit/index.d.ts +1 -1
  46. package/dist/version.d.ts +14 -1
  47. package/dist/version.js +14 -1
  48. package/dist/wc/{GridMenus-By9vPuq0.js → GridMenus-6_fF5jIm.js} +2 -2
  49. package/dist/wc/{SvGrid.helpers-BdVDtjUm.js → SvGrid.helpers-zBkDTryR.js} +36 -37
  50. package/dist/wc/{SvGridCellEditor-Bnf1Kr5C.js → SvGridCellEditor-CzUKEbkI.js} +1 -1
  51. package/dist/wc/{SvGridChart-BqUJh2pG.js → SvGridChart-C6UvNIbN.js} +3 -3
  52. package/dist/wc/{SvGridChartBuilder-CGycC5cn.js → SvGridChartBuilder-ikf_5LyW.js} +3 -3
  53. package/dist/wc/{SvGridChartPanel-CRnizsHD.js → SvGridChartPanel-Dc_jj7B-.js} +4 -4
  54. package/dist/wc/{SvGridChartView-D1pa_xMy.js → SvGridChartView-thkznAOb.js} +2 -2
  55. package/dist/wc/{cell-formatting-CBfgahv1.js → cell-formatting-hb3vzxhJ.js} +27 -5
  56. package/dist/wc/{chart-6OXlybVT.js → chart-CV5D7_k8.js} +1 -1
  57. package/dist/wc/{chart-summary-2m3ia1-n.js → chart-summary-DGx0QhJa.js} +1 -1
  58. package/dist/wc/{chart-trend-CQOQtTMl.js → chart-trend-BIRH_xH8.js} +1 -1
  59. package/dist/wc/sv-sheet-element.js +7454 -7352
  60. package/dist/wc/{svelte-virtualizer.svelte-CXtQy2TQ.js → svelte-virtualizer.svelte-Du4tjrIP.js} +7 -3
  61. package/dist/wc/{validate-BqDthkNr.js → validate-JAuMsk6Z.js} +36 -36
  62. package/dist/worker/columnar.d.ts +42 -0
  63. package/dist/worker/columnar.js +410 -0
  64. package/dist/worker/index.d.ts +12 -0
  65. package/dist/worker/index.js +12 -0
  66. package/dist/worker/infer-schema.d.ts +21 -0
  67. package/dist/worker/infer-schema.js +34 -0
  68. package/dist/worker/protocol.d.ts +73 -0
  69. package/dist/worker/protocol.js +14 -0
  70. package/dist/worker/serve.d.ts +44 -0
  71. package/dist/worker/serve.js +100 -0
  72. package/package.json +7 -3
  73. package/dist/designer/assets/SvGrid.helpers-BgT-gnCo.js +0 -1
  74. package/dist/designer/assets/cell-formatting-BbubJTVz.js +0 -1
  75. package/dist/designer/assets/export-format-DGckmgKr.js +0 -6
  76. package/dist/designer/assets/index-_JVFxt02.css +0 -1
  77. package/dist/designer/assets/src-DUQ4xE3x.js +0 -58
  78. package/dist/designer/assets/validate-C3B8QKsM.js +0 -1
@@ -14,12 +14,13 @@ import { scaffold, MANAGED_START, MANAGED_END } from './scaffold.js';
14
14
  import { themeTokenCss } from './themes.js';
15
15
  const MANAGED_NOTE = '// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.';
16
16
  const managed = (body) => `${MANAGED_START}\n${MANAGED_NOTE}\n${body}\n${MANAGED_END}`;
17
- function layoutFile(nav, appTitle, theme) {
17
+ function layoutFile(nav, appTitle, theme, stylesheets) {
18
+ const imports = stylesheets.map((css) => ` import '../${css}'\n`).join('');
18
19
  return {
19
20
  path: 'src/routes/+layout.svelte',
20
21
  description: 'App shell: a collapsible nav sidebar linking every entity screen (an off-canvas drawer on tablet/phone), with the routed page in the main area.',
21
22
  contents: `<script lang="ts">
22
- ${managed(` let { children } = $props()
23
+ ${managed(`${imports} let { children } = $props()
23
24
  const appTitle = ${JSON.stringify(appTitle)}
24
25
  const nav = ${JSON.stringify(nav)}
25
26
 
@@ -143,6 +144,7 @@ export function scaffoldApp(schemas, options = {}) {
143
144
  files.push(...scaffold(schema, { dataSource: options.dataSource }).files);
144
145
  }
145
146
  const nav = linked.map((s) => ({ href: `/${s.name}`, label: s.label ?? titleCase(s.name) }));
146
- files.push(layoutFile(nav, appTitle, { preset: options.theme, mode: options.dark ? 'dark' : 'light' }), homeFile(nav, appTitle));
147
+ const theme = { preset: options.theme, mode: options.dark ? 'dark' : 'light' };
148
+ files.push(layoutFile(nav, appTitle, theme, options.stylesheets ?? []), homeFile(nav, appTitle));
147
149
  return { files };
148
150
  }
@@ -213,7 +213,8 @@ ${managed(serverBody)}
213
213
  const pageBody = ` const source = createKitDataSource<${rowType}>({ endpoint: '${apiRoute}' })
214
214
  const columns = schemaToColumns(${constName})${lookupsSection}
215
215
 
216
- let state = $state<ServerState<${rowType}>>({
216
+ // Not "state": a local of that name makes svelte-check read $state as a store of it.
217
+ let view = $state<ServerState<${rowType}>>({
217
218
  rows: [], total: 0, loading: false, saving: false, error: null,
218
219
  pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {},
219
220
  })
@@ -221,7 +222,7 @@ ${managed(serverBody)}
221
222
  pageSize: 25,
222
223
  optimistic: true,
223
224
  getRowId: (r) => String(r.${idField}),
224
- onChange: (s) => (state = s),
225
+ onChange: (s) => (view = s),
225
226
  })
226
227
  controller.refresh()
227
228
 
@@ -231,7 +232,7 @@ ${managed(serverBody)}
231
232
  async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${rowType}> }) {
232
233
  if (mode === 'create') {
233
234
  await controller.createRow(values)
234
- controller.setPage(state.pageCount - 1) // jump to the new row (appended at the end)
235
+ controller.setPage(view.pageCount - 1) // jump to the new row (appended at the end)
235
236
  } else if (id) {
236
237
  await controller.updateRow(id, values)
237
238
  }
@@ -246,12 +247,11 @@ ${managed(serverBody)}
246
247
  path: `src/routes/${route}/+page.svelte`,
247
248
  description: 'The data screen: sort, filter, search, paging, multi-select delete, and create/edit - wired to the API route.',
248
249
  contents: `<script lang="ts">
249
- import { SvGrid, createServerDataSource } from '@svgrid/grid'
250
+ import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
250
251
  import {
251
252
  SvGridEditPanel,
252
253
  createKitDataSource,${hasLookups ? '\n createRelationLookup,' : ''}
253
254
  schemaToColumns,
254
- type ServerState,
255
255
  } from '@svgrid/enterprise'
256
256
  import { ${constName}, type ${rowType} } from '${schemaImport}'
257
257
 
@@ -273,12 +273,12 @@ ${themeTokenCss({ preset: options.theme, mode: options.dark ? 'dark' : 'light' }
273
273
  </div>
274
274
  </header>
275
275
 
276
- {#if state.error}<p class="page__error" role="alert">{state.error}</p>{/if}
276
+ {#if view.error}<p class="page__error" role="alert">{view.error}</p>{/if}
277
277
 
278
278
  <SvGrid
279
- data={state.rows}
279
+ data={view.rows}
280
280
  {columns}
281
- loading={state.loading}
281
+ loading={view.loading}
282
282
  loadingOverlay
283
283
  fitColumns
284
284
  enableRowSummaries={false}
@@ -301,11 +301,11 @@ ${themeTokenCss({ preset: options.theme, mode: options.dark ? 'dark' : 'light' }
301
301
  onRowSelectionChange={(_sel, rows) => (selected = rows)}
302
302
  showPagination
303
303
  externalPagination
304
- rowCount={state.total}
305
- pageIndex={state.pageIndex}
306
- pageSize={state.pageSize}
304
+ rowCount={view.total}
305
+ pageIndex={view.pageIndex}
306
+ pageSize={view.pageSize}
307
307
  onPaginationChange={({ pageIndex, pageSize }) => {
308
- if (pageSize !== state.pageSize) controller.setPageSize(pageSize)
308
+ if (pageSize !== view.pageSize) controller.setPageSize(pageSize)
309
309
  else controller.setPage(pageIndex)
310
310
  }}
311
311
  emptyMessage="No ${label} yet"
@@ -338,6 +338,7 @@ ${themeTokenCss({ preset: options.theme, mode: options.dark ? 'dark' : 'light' }
338
338
  export function mergeManaged(existing, generated) {
339
339
  if (!existing)
340
340
  return generated;
341
+ existing = migrateStateLocal(existing);
341
342
  const startIdx = existing.indexOf(MANAGED_START);
342
343
  const endIdx = existing.indexOf(MANAGED_END);
343
344
  if (startIdx === -1 || endIdx === -1 || endIdx < startIdx)
@@ -349,3 +350,19 @@ export function mergeManaged(existing, generated) {
349
350
  const newRegion = generated.slice(genStart, genEnd + MANAGED_END.length);
350
351
  return (existing.slice(0, startIdx) + newRegion + existing.slice(endIdx + MANAGED_END.length));
351
352
  }
353
+ /** The managed line pages scaffolded before the rename carried. */
354
+ const OLD_STATE_LOCAL = /\blet state = \$state<ServerState</;
355
+ /**
356
+ * Pages scaffolded before the rename kept their server state in a local named
357
+ * `state`, which svelte-check reads as a store subscription whenever `$state`
358
+ * appears. The managed region now calls it `view`, but the markup below the
359
+ * region is the user's and still says `state.rows`; a plain merge would leave it
360
+ * pointing at nothing. Rename the references too. Nothing else in such a page
361
+ * could be called `state` (the old local would have clashed), and member reads
362
+ * like `page.state` are skipped.
363
+ */
364
+ function migrateStateLocal(existing) {
365
+ if (!OLD_STATE_LOCAL.test(existing))
366
+ return existing;
367
+ return existing.replace(/(?<![.\w$])state(?=\s*[.?[]|\s*=[^=>])/g, 'view');
368
+ }
@@ -7,8 +7,28 @@
7
7
  */
8
8
  import type { RowData, ServerDataSource } from '@svgrid/grid';
9
9
  import { type EntitySchema } from '../schema.js';
10
+ import { type PlanPredicate, type QueryPlan } from './query-plan.js';
10
11
  import type { WritableDataSource } from './types.js';
11
12
  import { type AggregateSource } from '../sources/aggregate.js';
12
- export declare function createInMemoryDataSource<TData extends RowData>(initial: ReadonlyArray<TData>, schema: EntitySchema<TData>): WritableDataSource<TData> & Required<Pick<ServerDataSource<TData>, 'updateWhere'>> & AggregateSource & {
13
+ export declare function asString(v: unknown): string;
14
+ export declare function matchesPredicate(row: RowData, p: PlanPredicate): boolean;
15
+ export declare function sortRows<T extends RowData>(rows: T[], orderBy: QueryPlan['orderBy']): T[];
16
+ export type InMemoryDataSourceOptions = {
17
+ /**
18
+ * Keep the last few filtered, sorted (and grouped) results and answer a
19
+ * request that differs only in its row range by slicing one, instead of
20
+ * filtering and sorting the whole array again. Scrolling a block-loading
21
+ * row model asks for the same query once per block, so on a large array
22
+ * this is the difference between one sort and one sort per block.
23
+ *
24
+ * Off by default because a cached result does not see a row object that
25
+ * was changed in place, only changes made through this source's own
26
+ * `createRow` / `updateRow` / `deleteRow` / `updateWhere`, which drop the
27
+ * cache. `createWorkerDataSource` turns it on: there the rows live in the
28
+ * worker and nothing else can reach them.
29
+ */
30
+ cacheResults?: boolean;
31
+ };
32
+ export declare function createInMemoryDataSource<TData extends RowData>(initial: ReadonlyArray<TData>, schema: EntitySchema<TData>, options?: InMemoryDataSourceOptions): WritableDataSource<TData> & Required<Pick<ServerDataSource<TData>, 'updateWhere'>> & AggregateSource & {
13
33
  rows(): ReadonlyArray<TData>;
14
34
  };
@@ -2,13 +2,16 @@ import { resolveIdField } from '../schema.js';
2
2
  import { planQuery } from './query-plan.js';
3
3
  import { compilePredicate } from '../expressions/compile.js';
4
4
  import { aggregateRows } from '../sources/aggregate.js';
5
- function asString(v) {
5
+ // asString, matchesPredicate and sortRows are exported for the columnar
6
+ // engine behind the worker data source, which must answer exactly as this
7
+ // reference does. They are not part of the package API.
8
+ export function asString(v) {
6
9
  return v == null ? '' : String(v);
7
10
  }
8
11
  function num(v) {
9
12
  return typeof v === 'number' ? v : Number(v);
10
13
  }
11
- function matchesPredicate(row, p) {
14
+ export function matchesPredicate(row, p) {
12
15
  const cell = row[p.field];
13
16
  switch (p.op) {
14
17
  case 'eq':
@@ -103,28 +106,42 @@ function grandTotalOf(all, plan, expression) {
103
106
  function aggregate(rows, field, fn) {
104
107
  if (fn === 'count')
105
108
  return rows.length;
106
- const nums = [];
109
+ // One pass, no spread: `Math.min(...nums)` throws a RangeError once a
110
+ // group holds more than about 150,000 values (V8's argument limit).
111
+ let count = 0;
112
+ let sum = 0;
113
+ let min = Infinity;
114
+ let max = -Infinity;
107
115
  for (const row of rows) {
108
116
  const n = Number(row[field]);
109
- if (Number.isFinite(n))
110
- nums.push(n);
117
+ if (!Number.isFinite(n))
118
+ continue;
119
+ count += 1;
120
+ sum += n;
121
+ if (n < min)
122
+ min = n;
123
+ if (n > max)
124
+ max = n;
111
125
  }
112
- if (nums.length === 0)
126
+ if (count === 0)
113
127
  return null;
114
128
  switch (fn) {
115
129
  case 'sum':
116
- return nums.reduce((s, n) => s + n, 0);
130
+ return sum;
117
131
  case 'avg':
118
- return nums.reduce((s, n) => s + n, 0) / nums.length;
132
+ return sum / count;
119
133
  case 'min':
120
- return Math.min(...nums);
134
+ return min;
121
135
  case 'max':
122
- return Math.max(...nums);
136
+ return max;
123
137
  default:
124
138
  return null;
125
139
  }
126
140
  }
127
- function sortRows(rows, orderBy) {
141
+ // What `localeCompare` with no arguments does, without building a collator
142
+ // per comparison - which is most of the cost of sorting a large array.
143
+ const collator = new Intl.Collator();
144
+ export function sortRows(rows, orderBy) {
128
145
  if (orderBy.length === 0)
129
146
  return rows;
130
147
  // Stable multi-key sort: decorate with index, compare keys in order, fall back to index.
@@ -138,7 +155,7 @@ function sortRows(rows, orderBy) {
138
155
  if (typeof av === 'number' && typeof bv === 'number')
139
156
  cmp = av - bv;
140
157
  else
141
- cmp = asString(av).localeCompare(asString(bv));
158
+ cmp = collator.compare(asString(av), asString(bv));
142
159
  if (cmp !== 0)
143
160
  return desc ? -cmp : cmp;
144
161
  }
@@ -146,106 +163,137 @@ function sortRows(rows, orderBy) {
146
163
  })
147
164
  .map((d) => d.row);
148
165
  }
149
- export function createInMemoryDataSource(initial, schema) {
166
+ /** Results kept by `cacheResults`: a handful of sort / filter / group states. */
167
+ const RESULT_CACHE_SIZE = 8;
168
+ export function createInMemoryDataSource(initial, schema, options = {}) {
150
169
  let store = [...initial];
151
170
  const idField = resolveIdField(schema);
152
- return {
153
- async getRows(request) {
154
- const plan = planQuery(schema, request);
155
- let filtered = store.filter((r) => matches(r, plan));
156
- // Advanced filter. `planQuery` only admits an expression whose columns
157
- // are all on the schema, so reaching here means it is safe to run. If it
158
- // was rejected, or fails to compile, we must NOT acknowledge it - the
159
- // grid then tells the user the filter did not run rather than showing a
160
- // superset that looks filtered.
161
- let appliedExpression = false;
162
- let expressionPredicate = null;
163
- if (plan.expression) {
164
- const predicate = compilePredicate(plan.expression, {
165
- getValue: (row, columnId) => row[columnId],
166
- rows: filtered,
167
- });
168
- if (predicate) {
169
- filtered = filtered.filter(predicate);
170
- expressionPredicate = predicate;
171
- appliedExpression = true;
172
- }
171
+ // Every write reassigns `store`, so a cache filled from an older array is
172
+ // stale by identity alone.
173
+ const cache = new Map();
174
+ let cacheStore = null;
175
+ function prepare(plan) {
176
+ if (!options.cacheResults)
177
+ return run(plan);
178
+ if (cacheStore !== store) {
179
+ cache.clear();
180
+ cacheStore = store;
181
+ }
182
+ // The range is the only part of a plan that does not change the result.
183
+ const key = JSON.stringify({ ...plan, limit: 0, offset: 0 });
184
+ const hit = cache.get(key);
185
+ if (hit) {
186
+ // Re-insert so the Map's order is least-recently used first.
187
+ cache.delete(key);
188
+ cache.set(key, hit);
189
+ return hit;
190
+ }
191
+ const prepared = run(plan);
192
+ cache.set(key, prepared);
193
+ if (cache.size > RESULT_CACHE_SIZE)
194
+ cache.delete(cache.keys().next().value);
195
+ return prepared;
196
+ }
197
+ function run(plan) {
198
+ let filtered = store.filter((r) => matches(r, plan));
199
+ // Advanced filter. `planQuery` only admits an expression whose columns
200
+ // are all on the schema, so reaching here means it is safe to run. If it
201
+ // was rejected, or fails to compile, we must NOT acknowledge it - the
202
+ // grid then tells the user the filter did not run rather than showing a
203
+ // superset that looks filtered.
204
+ let appliedExpression = false;
205
+ let expressionPredicate = null;
206
+ if (plan.expression) {
207
+ const predicate = compilePredicate(plan.expression, {
208
+ getValue: (row, columnId) => row[columnId],
209
+ rows: filtered,
210
+ });
211
+ if (predicate) {
212
+ filtered = filtered.filter(predicate);
213
+ expressionPredicate = predicate;
214
+ appliedExpression = true;
173
215
  }
174
- // The grand total spans everything the FILTERS admit - the whole
175
- // store minus the filters and the expression, but NOT minus the group
176
- // path, which only scopes this request.
177
- const grandTotal = plan.grandTotal
178
- ? { grandTotal: grandTotalOf(store, plan, expressionPredicate) }
179
- : {};
180
- // Grouped request: return one row per distinct key at this level, each
181
- // carrying the requested aggregates. The chosen path is already in
182
- // `plan.where`, so `filtered` is exactly this group's slice of the data.
183
- // Reference behaviour for what a SQL backend does with `planToSql`'s
184
- // `select` / `groupByText` / `countText`.
185
- if (plan.groupBy) {
186
- const groupField = plan.groupBy;
187
- const buckets = new Map();
188
- for (const row of filtered) {
189
- const key = asString(row[groupField]);
190
- const bucket = buckets.get(key);
191
- if (bucket)
192
- bucket.push(row);
193
- else
194
- buckets.set(key, [row]);
195
- }
196
- // Pivoting: every aggregate is computed once per distinct pivot key
197
- // path inside the group, under a field named from the path, and the
198
- // field list goes back so the grid can build the columns.
199
- const pivotPaths = new Set();
200
- const groups = [...buckets.entries()].map(([key, rows]) => {
201
- const out = { [groupField]: key };
202
- aggregateInto(out, rows, plan, pivotPaths);
203
- // What opening this group would show: distinct keys of the next
204
- // level, or the rows themselves at the innermost group level.
205
- out.childCount = plan.childGroupBy
206
- ? new Set(rows.map((r) => asString(r[plan.childGroupBy]))).size
207
- : rows.length;
208
- return out;
209
- });
210
- // The pivoted fields in a fixed order - key paths in plain string
211
- // order, aggregations as requested under each - so the grid builds
212
- // the same columns whatever order the groups came in. Every group
213
- // row carries every field; a cell with no rows under it is null, the
214
- // way a conditional aggregate answers in SQL.
215
- const pivotFields = [...pivotPaths]
216
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
217
- .flatMap((path) => (plan.aggregations ?? []).map((agg) => `${path}_${agg.field}`));
218
- for (const g of groups) {
219
- for (const f of pivotFields)
220
- if (!(f in g))
221
- g[f] = null;
222
- }
223
- // Sort by the group key unless the request sorted on a column we
224
- // actually produced (the key or an aggregate).
225
- const produced = new Set([
226
- groupField,
227
- ...(plan.pivotBy?.length ? [] : (plan.aggregations ?? []).map((a) => a.field)),
228
- ]);
229
- const groupOrder = plan.orderBy.filter((o) => produced.has(o.field));
230
- const sorted = groupOrder.length
231
- ? sortRows(groups, groupOrder)
232
- : sortRows(groups, [{ field: groupField, desc: false }]);
233
- return {
234
- rows: sorted.slice(plan.offset, plan.offset + plan.limit),
235
- // The count is DISTINCT GROUPS, not underlying rows - the grid sizes
236
- // its scrollbar and paging from this.
237
- rowCount: sorted.length,
238
- appliedExpression,
239
- ...grandTotal,
240
- ...(plan.pivotBy?.length ? { pivotResultFields: pivotFields } : {}),
241
- };
216
+ }
217
+ // The grand total spans everything the FILTERS admit - the whole
218
+ // store minus the filters and the expression, but NOT minus the group
219
+ // path, which only scopes this request.
220
+ const grandTotal = plan.grandTotal
221
+ ? { grandTotal: grandTotalOf(store, plan, expressionPredicate) }
222
+ : {};
223
+ // Grouped request: return one row per distinct key at this level, each
224
+ // carrying the requested aggregates. The chosen path is already in
225
+ // `plan.where`, so `filtered` is exactly this group's slice of the data.
226
+ // Reference behaviour for what a SQL backend does with `planToSql`'s
227
+ // `select` / `groupByText` / `countText`.
228
+ if (plan.groupBy) {
229
+ const groupField = plan.groupBy;
230
+ const buckets = new Map();
231
+ for (const row of filtered) {
232
+ const key = asString(row[groupField]);
233
+ const bucket = buckets.get(key);
234
+ if (bucket)
235
+ bucket.push(row);
236
+ else
237
+ buckets.set(key, [row]);
238
+ }
239
+ // Pivoting: every aggregate is computed once per distinct pivot key
240
+ // path inside the group, under a field named from the path, and the
241
+ // field list goes back so the grid can build the columns.
242
+ const pivotPaths = new Set();
243
+ const groups = [...buckets.entries()].map(([key, rows]) => {
244
+ const out = { [groupField]: key };
245
+ aggregateInto(out, rows, plan, pivotPaths);
246
+ // What opening this group would show: distinct keys of the next
247
+ // level, or the rows themselves at the innermost group level.
248
+ out.childCount = plan.childGroupBy
249
+ ? new Set(rows.map((r) => asString(r[plan.childGroupBy]))).size
250
+ : rows.length;
251
+ return out;
252
+ });
253
+ // The pivoted fields in a fixed order - key paths in plain string
254
+ // order, aggregations as requested under each - so the grid builds
255
+ // the same columns whatever order the groups came in. Every group
256
+ // row carries every field; a cell with no rows under it is null, the
257
+ // way a conditional aggregate answers in SQL.
258
+ const pivotFields = [...pivotPaths]
259
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
260
+ .flatMap((path) => (plan.aggregations ?? []).map((agg) => `${path}_${agg.field}`));
261
+ for (const g of groups) {
262
+ for (const f of pivotFields)
263
+ if (!(f in g))
264
+ g[f] = null;
242
265
  }
243
- const sortedLeaves = sortRows(filtered, plan.orderBy);
266
+ // Sort by the group key unless the request sorted on a column we
267
+ // actually produced (the key or an aggregate).
268
+ const produced = new Set([
269
+ groupField,
270
+ ...(plan.pivotBy?.length ? [] : (plan.aggregations ?? []).map((a) => a.field)),
271
+ ]);
272
+ const groupOrder = plan.orderBy.filter((o) => produced.has(o.field));
273
+ const sorted = groupOrder.length
274
+ ? sortRows(groups, groupOrder)
275
+ : sortRows(groups, [{ field: groupField, desc: false }]);
244
276
  return {
245
- rows: sortedLeaves.slice(plan.offset, plan.offset + plan.limit),
246
- rowCount: sortedLeaves.length,
277
+ rows: sorted,
247
278
  appliedExpression,
248
279
  ...grandTotal,
280
+ ...(plan.pivotBy?.length ? { pivotFields } : {}),
281
+ };
282
+ }
283
+ return { rows: sortRows(filtered, plan.orderBy), appliedExpression, ...grandTotal };
284
+ }
285
+ return {
286
+ async getRows(request) {
287
+ const plan = planQuery(schema, request);
288
+ const prepared = prepare(plan);
289
+ return {
290
+ rows: prepared.rows.slice(plan.offset, plan.offset + plan.limit),
291
+ // On a grouped request the count is DISTINCT GROUPS, not underlying
292
+ // rows - the grid sizes its scrollbar and paging from this.
293
+ rowCount: prepared.rows.length,
294
+ appliedExpression: prepared.appliedExpression,
295
+ ...(prepared.grandTotal !== undefined ? { grandTotal: prepared.grandTotal } : {}),
296
+ ...(prepared.pivotFields ? { pivotResultFields: prepared.pivotFields } : {}),
249
297
  };
250
298
  },
251
299
  async createRow(input) {
@@ -1,5 +1,5 @@
1
1
  export { planQuery, coerce, BUILTIN_AGGREGATORS, type QueryPlan, type PlanPredicate, type PlanOp, type PlanAggregate, type PlanOptions, } from './query-plan.js';
2
- export { createInMemoryDataSource } from './in-memory.js';
2
+ export { createInMemoryDataSource, type InMemoryDataSourceOptions } from './in-memory.js';
3
3
  export { createKitDataSource, createKitHandlers, type KitDataSourceOptions, type KitHandlerOptions, type KitScope, type KitHandlers, type KitMessage, } from './transport.js';
4
4
  export { planToSql, type SqlDialect, type SqlPlan } from './sql.js';
5
5
  export { createSqlDataSource, type SqlExecutor, type SqlDataSourceConfig, } from './sql-source.js';
package/dist/version.d.ts CHANGED
@@ -10,4 +10,17 @@
10
10
  *
11
11
  * `version.test.ts` asserts this matches `package.json`, so the two cannot drift.
12
12
  */
13
- export declare const SVGRID_VERSION = "3.1.0";
13
+ export declare const SVGRID_VERSION = "3.1.2";
14
+ /**
15
+ * The version of `@svgrid/grid`, which releases on its own line: enterprise is
16
+ * on 3.1.x while the grid is on 3.0.x.
17
+ *
18
+ * A scaffolded app depends on BOTH packages, and pinning the grid to this
19
+ * package's version asked npm for `@svgrid/grid@^3.1.1`, which has never been
20
+ * published. Every `svgrid-studio init` therefore produced an app whose very
21
+ * next step, `npm install`, failed with ERESOLVE. Found 2026-10-05 by
22
+ * scaffolding one and running the install.
23
+ *
24
+ * `version.test.ts` asserts this matches packages/grid/package.json.
25
+ */
26
+ export declare const GRID_VERSION = "3.0.8";
package/dist/version.js CHANGED
@@ -10,4 +10,17 @@
10
10
  *
11
11
  * `version.test.ts` asserts this matches `package.json`, so the two cannot drift.
12
12
  */
13
- export const SVGRID_VERSION = '3.1.0';
13
+ export const SVGRID_VERSION = '3.1.2';
14
+ /**
15
+ * The version of `@svgrid/grid`, which releases on its own line: enterprise is
16
+ * on 3.1.x while the grid is on 3.0.x.
17
+ *
18
+ * A scaffolded app depends on BOTH packages, and pinning the grid to this
19
+ * package's version asked npm for `@svgrid/grid@^3.1.1`, which has never been
20
+ * published. Every `svgrid-studio init` therefore produced an app whose very
21
+ * next step, `npm install`, failed with ERESOLVE. Found 2026-10-05 by
22
+ * scaffolding one and running the install.
23
+ *
24
+ * `version.test.ts` asserts this matches packages/grid/package.json.
25
+ */
26
+ export const GRID_VERSION = '3.0.8';
@@ -1,6 +1,6 @@
1
1
  import { $ as e, D as t, G as n, H as r, I as i, L as a, M as o, O as s, P as c, Q as l, R as u, S as d, T as f, U as p, W as m, X as h, Y as g, Z as _, _ as v, a as y, at as b, b as x, d as S, dt as C, et as w, f as T, h as E, ht as D, it as O, j as k, k as A, l as ee, m as j, mt as M, n as N, nt as P, o as F, p as te, pt as I, q as L, st as R, tt as z, u as ne, ut as B, w as re, x as V, y as H, z as U } from "./client-DQIzjDBG.js";
2
- import { c as W, s as ie, t as ae } from "./svelte-virtualizer.svelte-CXtQy2TQ.js";
3
- import { S as oe, h as se, u as ce, w as le } from "./SvGrid.helpers-BdVDtjUm.js";
2
+ import { c as W, s as ie, t as ae } from "./svelte-virtualizer.svelte-Du4tjrIP.js";
3
+ import { S as oe, h as se, u as ce, w as le } from "./SvGrid.helpers-zBkDTryR.js";
4
4
  import { i as ue, t as G } from "./editor-contract-peBDxir_.js";
5
5
  import { t as K } from "./SvField-B_kurx_P.js";
6
6
  import { n as de } from "./dismissable-CQ6XFXsk.js";