@svadmin/core 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  // Core barrel exports
2
+ export { decodeApprovalRecord, decodeApprovalList, decodeApprovalTransition, decodeApprovalReceipt, ApprovalContractError } from './approval-contract';
3
+ export type { ApprovalRecord, ApprovalTransition, ApprovalList, ApprovalReceipt, ApprovalProvider } from './approval-contract';
2
4
 
3
5
  export {
4
6
  setDataProvider, getDataProvider, getDataProviderForResource, getDataProviderNames,
@@ -114,6 +116,12 @@ export {
114
116
  export type { NotificationParams } from './notification.svelte';
115
117
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation, setI18nProvider, getI18nProvider, createI18nScope, provideI18nScope, getI18nScope, resetI18n } from './i18n.svelte';
116
118
  export type { I18nProvider, I18nScope, I18nScopeOptions } from './i18n.svelte';
119
+ export { buildPivot, buildPivotCacheKey, buildPivotDrilldown, buildPivotExportRequest, decodePivotResult, snapshotPivotQuery, snapshotPivotExportTaskResult, formatPivotValue, PIVOT_LIMITS } from './pivot';
120
+ export type { PivotAggregation, PivotCache, PivotDimension, PivotDimensionValue, PivotOptions, PivotPayload, PivotProvider, PivotQuery, PivotResult, PivotDrilldown, PivotExportRequest, PivotExportTaskResult } from './pivot';
121
+ export { civilDateTimeToInstant, parseDateTimeInstant, formatCivilDateTime } from './date-time';
122
+ export type { DateTimeValueMode, DateTimeDisambiguation } from './date-time';
123
+ export { validateGantt, GANTT_LIMITS } from './gantt';
124
+ export type { GanttTaskShape, GanttValidationError } from './gantt';
117
125
  export { audit, auditWithProvider, writeAuditEntry, recordMutationRollback, setAuditHandler, setAuditLogProvider, getAuditLogProvider, withValidatedAuditProvider, AuditError } from './audit';
118
126
  export type { AuditLogProvider, AuditLogTransport, AuditCreateParams, AuditQueryParams, AuditDraft } from './audit';
119
127
  export { TaskError } from './task-contract';
@@ -143,7 +151,17 @@ export type {
143
151
  WebhookSummary,
144
152
  } from './enterprise';
145
153
  export { assertEnterpriseRequestContext, createEnterpriseRequestContext } from './enterprise';
154
+ export {
155
+ DashboardContractError, decodeDashboardQuery, decodeDashboardSnapshot,
156
+ type DashboardMetric, type DashboardProvider, type DashboardQuery, type DashboardSnapshot, type DashboardWidget,
157
+ } from './dashboard-contract';
146
158
  export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider, defineAdminTool, decodeAdminToolArgs, executeAdminTool, projectAdminToolSchema } from './chatProvider.svelte';
159
+ export { ENTERPRISE_MATURITY, enterpriseMaturityFor } from './enterprise-maturity';
160
+ export type {
161
+ EnterpriseMaturityEntry,
162
+ EnterpriseMaturityStatus,
163
+ EnterpriseMaturityTier,
164
+ } from './enterprise-maturity';
147
165
  export type {
148
166
  ChatProvider, ChatMessage, ChatMessagePart, ChatContext, ChatAttachment, ChatSource,
149
167
  MessageStatus, ToolState, AgentProvider, AgentEvent, AgentOptions, ApprovalResponseOptions,
@@ -175,7 +193,7 @@ export type {
175
193
  DeleteManyParams, DeleteManyResult,
176
194
  Pagination, Sort, Filter, Identity,
177
195
  ResourceDefinition, ResourceProviderConfig, ResourceTransportConfig, ResourceAdapterConfig,
178
- FieldDefinition, MenuItem,
196
+ FieldDefinition, DateInputOptions, MenuItem,
179
197
  AuthActionResult, CheckResult,
180
198
  ResourceTypeMap, ResourceInputMap, ResourceInputOperation, InferResourceInput, KnownResources, InferData,
181
199
  BaseRecord, Role, AuditLog
@@ -190,9 +208,26 @@ export { createCaslAccessControl } from './adapters/casl';
190
208
  export { createCasbinAccessControl } from './adapters/casbin';
191
209
  export type { CasbinAdapterOptions } from './adapters/casbin';
192
210
  export { downloadData, toCsv, toJson, toXlsx, parseCSV } from './data-transfer.svelte';
211
+ export { parseSpreadsheetClipboard, serializeSpreadsheetClipboard } from './spreadsheet-clipboard';
212
+ export { parseSpreadsheetWorkbook, serializeSpreadsheetWorkbook, snapshotSpreadsheetWorkbook, SPREADSHEET_WORKBOOK_LIMITS } from './spreadsheet-workbook';
213
+ export type { SpreadsheetCellFormat, SpreadsheetSheet, SpreadsheetWorkbook } from './spreadsheet-workbook';
214
+ export { FILTER_COLLECTION_LIMIT, isNumericFilterField, isCollectionFilterOperator,
215
+ filterOperatorsForField, isFilterScalarValue, isFilterCollectionValue } from './filter-values';
216
+ export {
217
+ assertSchemaFormSchema, prepareSchemaFormValue, readSchemaFormPath,
218
+ matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
219
+ } from './schema-form';
220
+ export type {
221
+ SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
222
+ } from './schema-form';
193
223
  export { useExport } from './export-hooks.svelte';
224
+ export { snapshotExportTaskResult, downloadExportArtifact, safeArtifactUrl } from './export-format';
225
+ export type { ExportTaskResult } from './export-format';
194
226
  export type { UseExportOptions } from './export-hooks.svelte';
195
227
  export { useImport } from './import-hooks.svelte';
228
+ export type { ImportArtifact, ImportArtifactProvider, ImportTaskResult } from './import-contract';
229
+ export { snapshotImportArtifact, snapshotImportTaskResult } from './import-contract';
230
+ export { IMPORT_LIMITS } from './import-contract';
196
231
  export type { UseImportOptions, ImportProgress, ImportResult } from './import-hooks.svelte';
197
232
  export type { ExportFormat } from './data-transfer.svelte';
198
233
  export {
package/src/inferencer.ts CHANGED
@@ -351,6 +351,15 @@ export function generateTypeBoxSchemaCode(resource: ResourceDefinition): string
351
351
  case 'date':
352
352
  typeDef = "Type.String({ format: 'date-time' })";
353
353
  break;
354
+ case 'datetime':
355
+ case 'time':
356
+ // 原生输入没有强制时区,不把本地时间误标为 RFC3339 date-time。
357
+ typeDef = 'Type.String()';
358
+ break;
359
+ case 'daterange':
360
+ // 与 DateRangeInput 一致:两个端点明确存在,未选择时为 null。
361
+ typeDef = 'Type.Union([Type.Object({ start: Type.Union([Type.String(), Type.Null()]), end: Type.Union([Type.String(), Type.Null()]) }, { additionalProperties: false }), Type.Null()])';
362
+ break;
354
363
  case 'email':
355
364
  typeDef = "Type.String({ format: 'email' })";
356
365
  break;
package/src/pivot.ts ADDED
@@ -0,0 +1,367 @@
1
+ import type { Filter } from './types';
2
+ import { snapshotPlainData } from './plain-data';
3
+ import { snapshotListParams } from './query-snapshot';
4
+ import { snapshotExportTaskResult, type ExportFormat, type ExportTaskResult } from './export-format';
5
+
6
+ export type PivotAggregation = 'sum' | 'count' | 'avg' | 'min' | 'max';
7
+ export type PivotDimensionValue = string | number | boolean | null;
8
+ export interface PivotDimension {
9
+ key: string;
10
+ value: PivotDimensionValue;
11
+ label: string;
12
+ }
13
+ export interface PivotOptions {
14
+ rowField: string;
15
+ columnField: string;
16
+ valueField: string;
17
+ aggregator?: PivotAggregation;
18
+ }
19
+ export interface PivotResult {
20
+ error?: 'invalidData' | 'limit' | 'invalidNumber' | 'overflow';
21
+ rows: PivotDimension[];
22
+ columns: PivotDimension[];
23
+ cells: Map<string, Map<string, number | null>>;
24
+ rowTotals: Map<string, number | null>;
25
+ columnTotals: Map<string, number | null>;
26
+ grandTotal: number | null;
27
+ }
28
+
29
+ export interface PivotQuery extends PivotOptions {
30
+ resource: string;
31
+ filters?: Filter[];
32
+ meta?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface PivotDrilldown {
36
+ resource: string;
37
+ scopeKey: string;
38
+ rowField: string;
39
+ columnField: string;
40
+ valueField: string;
41
+ aggregator: PivotAggregation;
42
+ row: PivotDimension;
43
+ column: PivotDimension;
44
+ filters?: Filter[];
45
+ meta?: Record<string, unknown>;
46
+ }
47
+
48
+ export interface PivotProvider {
49
+ aggregate: (query: PivotQuery) => Promise<unknown>;
50
+ }
51
+
52
+ export interface PivotCache {
53
+ /** 相同缓存中的不同数据源必须使用不同命名空间。 */
54
+ providerKey: string;
55
+ get: (key: string) => Promise<unknown | undefined> | unknown | undefined;
56
+ set: (key: string, value: unknown) => Promise<void> | void;
57
+ }
58
+
59
+ export function buildPivotCacheKey(query: unknown, scopeKey: unknown, providerKey: unknown): string | undefined {
60
+ const snapshot = snapshotPivotQuery(query);
61
+ if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000
62
+ || typeof providerKey !== 'string' || !providerKey.trim() || providerKey.length > 4000) return;
63
+ return JSON.stringify({ protocolVersion: 1, providerKey, scopeKey, query: snapshot });
64
+ }
65
+
66
+ export interface PivotExportRequest {
67
+ protocolVersion: 1;
68
+ kind: 'pivot';
69
+ scopeKey: string;
70
+ format: ExportFormat;
71
+ query: PivotQuery;
72
+ requestFingerprint: string;
73
+ }
74
+
75
+ export interface PivotExportTaskResult extends ExportTaskResult {
76
+ kind: 'pivot';
77
+ taskName: string;
78
+ idempotencyKey: string;
79
+ scopeKey: string;
80
+ requestFingerprint: string;
81
+ }
82
+
83
+ /** 只生成服务端导出请求,不将当前页或预聚合单元格当作原始记录导出。 */
84
+ export function buildPivotExportRequest(query: unknown, scopeKey: unknown, format: unknown = 'csv'): PivotExportRequest | undefined {
85
+ const snapshot = snapshotPivotQuery(query);
86
+ if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000
87
+ || (format !== 'csv' && format !== 'json' && format !== 'xlsx')) return;
88
+ const request: Omit<PivotExportRequest, 'requestFingerprint'> = { protocolVersion: 1, kind: 'pivot', scopeKey, format, query: snapshot };
89
+ return { ...request, requestFingerprint: JSON.stringify(request) };
90
+ }
91
+
92
+ export function snapshotPivotExportTaskResult(value: unknown, expected: {
93
+ taskName: string; idempotencyKey: string; request: PivotExportRequest;
94
+ }): PivotExportTaskResult | undefined {
95
+ try {
96
+ const plain = snapshotPlainData(value);
97
+ if (!isRecord(plain)) return;
98
+ const { kind, taskName, idempotencyKey, scopeKey, requestFingerprint, ...artifact } = plain;
99
+ const result = snapshotExportTaskResult(artifact);
100
+ if (!result || kind !== 'pivot' || taskName !== expected.taskName
101
+ || idempotencyKey !== expected.idempotencyKey || scopeKey !== expected.request.scopeKey
102
+ || requestFingerprint !== expected.request.requestFingerprint
103
+ || result.format !== expected.request.format) return;
104
+ return { ...result, kind: 'pivot', taskName: expected.taskName,
105
+ idempotencyKey: expected.idempotencyKey, scopeKey: expected.request.scopeKey,
106
+ requestFingerprint: expected.request.requestFingerprint };
107
+ } catch { return; }
108
+ }
109
+
110
+ /** 保留原条件树的顶层 AND 语义,不能把维度约束塞入已有 OR 组。 */
111
+ export function buildPivotDrilldown(query: unknown, scopeKey: unknown, row: unknown, column: unknown): PivotDrilldown | undefined {
112
+ try {
113
+ const snapshot = snapshotPivotQuery(query);
114
+ if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000) return;
115
+ const rowSnapshot = wireDimension(snapshotPlainData(row));
116
+ const columnSnapshot = wireDimension(snapshotPlainData(column));
117
+ if (!rowSnapshot || !columnSnapshot) return;
118
+ const constraints: Filter[] = [
119
+ { field: snapshot.rowField, operator: rowSnapshot.value === null ? 'null' : 'eq', value: rowSnapshot.value },
120
+ { field: snapshot.columnField, operator: columnSnapshot.value === null ? 'null' : 'eq', value: columnSnapshot.value },
121
+ ];
122
+ const narrowed = snapshotPivotQuery({ ...snapshot, filters: [...snapshot.filters ?? [], ...constraints] });
123
+ if (!narrowed) return;
124
+ return {
125
+ resource: narrowed.resource,
126
+ rowField: narrowed.rowField,
127
+ columnField: narrowed.columnField,
128
+ valueField: narrowed.valueField,
129
+ aggregator: narrowed.aggregator ?? 'sum',
130
+ scopeKey,
131
+ row: rowSnapshot,
132
+ column: columnSnapshot,
133
+ ...(narrowed.filters ? { filters: narrowed.filters } : {}),
134
+ ...(narrowed.meta ? { meta: narrowed.meta } : {}),
135
+ };
136
+ } catch {
137
+ return;
138
+ }
139
+ }
140
+
141
+ /** 服务端计算所有汇总值;客户端不能对预聚合结果再次求平均或计数。 */
142
+ export interface PivotPayload {
143
+ rows: PivotDimension[];
144
+ columns: PivotDimension[];
145
+ cells: Array<{ rowKey: string; columnKey: string; value: number | null }>;
146
+ rowTotals: Array<{ key: string; value: number | null }>;
147
+ columnTotals: Array<{ key: string; value: number | null }>;
148
+ grandTotal: number | null;
149
+ }
150
+
151
+ export function snapshotPivotQuery(value: unknown): PivotQuery | undefined {
152
+ try {
153
+ const plain = snapshotPlainData(value);
154
+ if (!isRecord(plain)) return;
155
+ const { rowField, columnField, valueField, aggregator = 'sum', ...base } = plain;
156
+ if (![rowField, columnField, valueField].every(field => typeof field === 'string'
157
+ && field.trim().length > 0 && field.length <= PIVOT_LIMITS.dimensionLength)
158
+ || typeof aggregator !== 'string' || !['sum', 'count', 'avg', 'min', 'max'].includes(aggregator)) return;
159
+ const query = snapshotListParams(base);
160
+ if (Object.keys(base).some(key => !['resource', 'filters', 'meta'].includes(key))) return;
161
+ return {
162
+ ...query, rowField: rowField as string, columnField: columnField as string,
163
+ valueField: valueField as string, aggregator: aggregator as PivotAggregation,
164
+ };
165
+ } catch {
166
+ return;
167
+ }
168
+ }
169
+
170
+ export const PIVOT_LIMITS = Object.freeze({
171
+ records: 10_000,
172
+ rows: 200,
173
+ columns: 200,
174
+ cells: 40_000,
175
+ dimensionLength: 1000,
176
+ });
177
+
178
+ function isRecord(value: unknown): value is Record<string, unknown> {
179
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
180
+ }
181
+
182
+ interface Aggregate {
183
+ count: number;
184
+ value: number;
185
+ }
186
+
187
+ function dimension(value: unknown): PivotDimension | undefined {
188
+ if (value == null) return { key: 'null:', value: null, label: '—' };
189
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') return;
190
+ if (typeof value === 'number' && !Number.isFinite(value)) return;
191
+ if (typeof value === 'string' && value.length > PIVOT_LIMITS.dimensionLength) return;
192
+ return { key: `${typeof value}:${value}`, value, label: String(value) };
193
+ }
194
+
195
+ function sortedDimensions(values: Map<string, PivotDimension>): PivotDimension[] {
196
+ const labels = new Map<string, number>();
197
+ for (const item of values.values()) labels.set(item.label, (labels.get(item.label) ?? 0) + 1);
198
+ // 同一轴存在歧义时统一转义所有文本,避免加引号后与另一个原始标签重名。
199
+ const quoteStrings = [...labels.values()].some(count => count > 1) || values.has('string:');
200
+ return [...values.values()].sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : a.key < b.key ? -1 : 1)
201
+ .map(item => ({
202
+ ...item,
203
+ label: quoteStrings && typeof item.value === 'string'
204
+ ? JSON.stringify(item.value) : item.label,
205
+ }));
206
+ }
207
+
208
+ function result(error?: PivotResult['error']): PivotResult {
209
+ return {
210
+ ...(error === undefined ? {} : { error }),
211
+ rows: [], columns: [], cells: new Map(), rowTotals: new Map(), columnTotals: new Map(), grandTotal: null,
212
+ };
213
+ }
214
+
215
+ function wireDimension(value: unknown): PivotDimension | undefined {
216
+ if (!isRecord(value) || typeof value['key'] !== 'string' || typeof value['label'] !== 'string') return;
217
+ const dimensionValue = value['value'];
218
+ if (dimensionValue !== null && typeof dimensionValue !== 'string'
219
+ && typeof dimensionValue !== 'number' && typeof dimensionValue !== 'boolean') return;
220
+ if (typeof dimensionValue === 'number' && !Number.isFinite(dimensionValue)) return;
221
+ if (!value['key'].trim() || !value['label'].trim()
222
+ || value['key'].length > PIVOT_LIMITS.dimensionLength || value['label'].length > PIVOT_LIMITS.dimensionLength
223
+ || (typeof dimensionValue === 'string' && dimensionValue.length > PIVOT_LIMITS.dimensionLength)) return;
224
+ return { key: value['key'], value: dimensionValue, label: value['label'] };
225
+ }
226
+
227
+ function wireNumber(value: unknown): number | null | undefined {
228
+ if (value === null) return null;
229
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
230
+ }
231
+
232
+ function wireTotals(value: unknown, keys: Set<string>): Map<string, number | null> | undefined {
233
+ if (!Array.isArray(value) || value.length !== keys.size) return;
234
+ const result = new Map<string, number | null>();
235
+ for (const item of value) {
236
+ if (!isRecord(item) || typeof item['key'] !== 'string' || !keys.has(item['key']) || result.has(item['key'])) return;
237
+ const number = wireNumber(item['value']);
238
+ if (number === undefined) return;
239
+ result.set(item['key'], number);
240
+ }
241
+ return result;
242
+ }
243
+
244
+ /** 服务端汇总回执必须是有界纯数据,不能包含访问器或未知维度引用。 */
245
+ export function decodePivotResult(value: unknown): PivotResult {
246
+ try {
247
+ const plain = snapshotPlainData(value);
248
+ if (!isRecord(plain) || !Array.isArray(plain['rows']) || !Array.isArray(plain['columns'])
249
+ || !Array.isArray(plain['cells'])) return result('invalidData');
250
+ if (plain['rows'].length > PIVOT_LIMITS.rows || plain['columns'].length > PIVOT_LIMITS.columns
251
+ || plain['cells'].length > PIVOT_LIMITS.cells) return result('limit');
252
+ const rows: PivotDimension[] = [];
253
+ const columns: PivotDimension[] = [];
254
+ const rowKeys = new Set<string>();
255
+ const columnKeys = new Set<string>();
256
+ for (const item of plain['rows']) {
257
+ const dimension = wireDimension(item);
258
+ if (!dimension || rowKeys.has(dimension.key)) return result('invalidData');
259
+ rowKeys.add(dimension.key);
260
+ rows.push(dimension);
261
+ }
262
+ for (const item of plain['columns']) {
263
+ const dimension = wireDimension(item);
264
+ if (!dimension || columnKeys.has(dimension.key)) return result('invalidData');
265
+ columnKeys.add(dimension.key);
266
+ columns.push(dimension);
267
+ }
268
+ if (rows.length * columns.length > PIVOT_LIMITS.cells) return result('limit');
269
+ const cells = new Map<string, Map<string, number | null>>();
270
+ for (const item of plain['cells']) {
271
+ if (!isRecord(item) || typeof item['rowKey'] !== 'string' || typeof item['columnKey'] !== 'string'
272
+ || !rowKeys.has(item['rowKey']) || !columnKeys.has(item['columnKey'])) return result('invalidData');
273
+ if (cells.get(item['rowKey'])?.has(item['columnKey'])) return result('invalidData');
274
+ const number = wireNumber(item['value']);
275
+ if (number === undefined) return result('invalidNumber');
276
+ let row = cells.get(item['rowKey']);
277
+ if (!row) { row = new Map(); cells.set(item['rowKey'], row); }
278
+ row.set(item['columnKey'], number);
279
+ }
280
+ const rowTotals = wireTotals(plain['rowTotals'], rowKeys);
281
+ const columnTotals = wireTotals(plain['columnTotals'], columnKeys);
282
+ const grandTotal = wireNumber(plain['grandTotal']);
283
+ if (!rowTotals || !columnTotals || grandTotal === undefined) return result('invalidData');
284
+ return { rows, columns, cells, rowTotals, columnTotals, grandTotal };
285
+ } catch {
286
+ return result('invalidData');
287
+ }
288
+ }
289
+
290
+ function add(aggregate: Aggregate, value: number, operator: PivotAggregation): boolean {
291
+ aggregate.count++;
292
+ switch (operator) {
293
+ case 'count': aggregate.value = aggregate.count; break;
294
+ case 'sum': aggregate.value += value; break;
295
+ // 分别缩放两项,避免有限输入的均值因中间求和溢出。
296
+ case 'avg': aggregate.value = aggregate.value * ((aggregate.count - 1) / aggregate.count) + value / aggregate.count; break;
297
+ case 'min': aggregate.value = aggregate.count === 1 ? value : Math.min(aggregate.value, value); break;
298
+ case 'max': aggregate.value = aggregate.count === 1 ? value : Math.max(aggregate.value, value); break;
299
+ }
300
+ return Number.isFinite(aggregate.value);
301
+ }
302
+
303
+ function valueOf(aggregate: Aggregate): number | null {
304
+ return aggregate.count ? aggregate.value : null;
305
+ }
306
+
307
+ /** 有界的记录聚合;不把分页数据解释为整个资源的统计结果。 */
308
+ export function buildPivot(data: readonly Record<string, unknown>[], options: PivotOptions): PivotResult {
309
+ const operator = options.aggregator ?? 'sum';
310
+ if (!Array.isArray(data) || !['sum', 'count', 'avg', 'min', 'max'].includes(operator)
311
+ || ![options.rowField, options.columnField, options.valueField].every(field => typeof field === 'string' && field.length > 0)) {
312
+ return result('invalidData');
313
+ }
314
+ if (data.length > PIVOT_LIMITS.records) return result('limit');
315
+ const rows = new Map<string, PivotDimension>();
316
+ const columns = new Map<string, PivotDimension>();
317
+ const cells = new Map<string, Map<string, Aggregate>>();
318
+ const rowTotals = new Map<string, Aggregate>();
319
+ const columnTotals = new Map<string, Aggregate>();
320
+ const grand: Aggregate = { count: 0, value: 0 };
321
+ for (const record of data) {
322
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return result('invalidData');
323
+ const own = (field: string) => Object.hasOwn(record, field) ? record[field] : undefined;
324
+ const row = dimension(own(options.rowField));
325
+ const column = dimension(own(options.columnField));
326
+ if (!row || !column) return result('invalidData');
327
+ rows.set(row.key, row);
328
+ columns.set(column.key, column);
329
+ if (rows.size > PIVOT_LIMITS.rows || columns.size > PIVOT_LIMITS.columns
330
+ || rows.size * columns.size > PIVOT_LIMITS.cells) return result('limit');
331
+ if (!rowTotals.has(row.key)) rowTotals.set(row.key, { count: 0, value: 0 });
332
+ if (!columnTotals.has(column.key)) columnTotals.set(column.key, { count: 0, value: 0 });
333
+ let rowCells = cells.get(row.key);
334
+ if (!rowCells) { rowCells = new Map(); cells.set(row.key, rowCells); }
335
+ let cell = rowCells.get(column.key);
336
+ if (!cell) { cell = { count: 0, value: 0 }; rowCells.set(column.key, cell); }
337
+ const raw = operator === 'count' ? 1 : own(options.valueField);
338
+ if (raw == null) continue;
339
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return result('invalidNumber');
340
+ const rowTotal = rowTotals.get(row.key);
341
+ const columnTotal = columnTotals.get(column.key);
342
+ if (!rowTotal || !columnTotal) return result('invalidData');
343
+ const targets = [cell, rowTotal, columnTotal, grand];
344
+ for (const target of targets) {
345
+ if (!add(target, raw, operator)) return result('overflow');
346
+ }
347
+ }
348
+ return {
349
+ rows: sortedDimensions(rows),
350
+ columns: sortedDimensions(columns),
351
+ cells: new Map([...cells].map(([key, values]) => [key, new Map([...values].map(([col, agg]) => [col, valueOf(agg)]))])),
352
+ rowTotals: new Map([...rowTotals].map(([key, agg]) => [key, valueOf(agg)])),
353
+ columnTotals: new Map([...columnTotals].map(([key, agg]) => [key, valueOf(agg)])),
354
+ grandTotal: valueOf(grand),
355
+ };
356
+ }
357
+
358
+ /** 空格和异常格式化器不能把一个有值的单元格伪装为空值。 */
359
+ export function formatPivotValue(value: number | null | undefined, formatter: (value: number) => string): string {
360
+ if (value == null) return '—';
361
+ try {
362
+ const formatted = formatter(value);
363
+ return typeof formatted === 'string' && formatted.trim() ? formatted : String(value);
364
+ } catch {
365
+ return String(value);
366
+ }
367
+ }
@@ -26,6 +26,8 @@ export interface QuerySessionOptions {
26
26
  isTargetCurrent?: () => boolean;
27
27
  /** Allow a shared query function to delegate work to another live observer. */
28
28
  allowRetiredQueryFn?: boolean;
29
+ /** Internal domain adapter; must return a sanitized error without provider diagnostics. */
30
+ normalizeError?: (error: unknown) => Error;
29
31
  }
30
32
 
31
33
  export function supersededQuerySession(): HttpError {
@@ -88,6 +90,8 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
88
90
  origin.provider === context.authProvider && origin.tenant === context.tenantCacheKey?.__svadminTenant &&
89
91
  origin.router === context.routerProvider && (origin.isTargetCurrent === undefined || origin.isTargetCurrent() === true);
90
92
  const current = (origin: Origin) => targetCurrent(origin) && sessionCurrent(origin);
93
+ const errorFor = (origin: Origin, error: unknown): Error =>
94
+ origin.normalizeError ? origin.normalizeError(error) : querySessionError(error);
91
95
 
92
96
  function notify(origin: Origin, type: 'success' | 'error', value: unknown): void {
93
97
  try {
@@ -125,7 +129,7 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
125
129
  });
126
130
  return;
127
131
  }
128
- const error = querySessionError(result.error);
132
+ const error = errorFor(origin, result.error);
129
133
  query.setState({
130
134
  error,
131
135
  errorUpdatedAt: now,
@@ -154,7 +158,7 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
154
158
  throw supersededQuerySession();
155
159
  }
156
160
  if (!current(origin)) settleDetached(origin, { success: false, error });
157
- throw querySessionError(error);
161
+ throw errorFor(origin, error);
158
162
  }
159
163
  }
160
164
 
@@ -174,24 +178,29 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
174
178
  untrack(() => {
175
179
  void handleAuthError(querySessionError(result.error), context, () => targetCurrent(origin),
176
180
  () => clearAuthQueries(client, origin.provider), origin.auth).catch(() => {});
177
- notify(origin, 'error', querySessionError(result.error));
181
+ notify(origin, 'error', errorFor(origin, result.error));
178
182
  });
179
183
  }
180
184
  });
181
185
  }
186
+ // 清理微任务只读取普通快照,不访问已经销毁或退出动画中的派生状态。
187
+ let lastQueryKey: QueryKey | undefined;
188
+ $effect(() => { lastQueryKey = options.queryKey; });
182
189
  $effect(() => () => {
183
190
  disposed = true;
191
+ const queryKey = lastQueryKey;
192
+ if (!queryKey) return;
184
193
  queueMicrotask(() => {
185
- const query = client.getQueryCache().find({ queryKey: options.queryKey, exact: true });
194
+ const query = client.getQueryCache().find({ queryKey, exact: true });
186
195
  if (query?.getObserversCount() === 0) void query.cancel();
187
196
  });
188
197
  });
189
- return { get options() { return options; }, client, current, sessionCurrent, run, observe };
198
+ return { get options() { return options; }, client, current, sessionCurrent, run, observe, errorFor };
190
199
  }
191
200
 
192
201
  export function pendingQueryResult<T, F extends (settings?: RefetchOptions) => Promise<unknown> = QueryObserverResult<T, unknown>['refetch']>(
193
202
  refetch: F,
194
- ): Omit<QueryObserverPendingResult<T, unknown>, 'refetch'> & { refetch: F } {
203
+ ): Omit<QueryObserverPendingResult<T, Error>, 'refetch'> & { refetch: F } {
195
204
  return {
196
205
  data: undefined, dataUpdatedAt: 0, error: null, errorUpdatedAt: 0,
197
206
  failureCount: 0, failureReason: null, errorUpdateCount: 0,