@svadmin/core 0.53.0 → 0.55.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,
@@ -65,7 +67,7 @@ export {
65
67
  useCreate, useUpdate, useDelete, useCreateMany, useUpdateMany, useDeleteMany, useForm,
66
68
  useInvalidate,
67
69
  } from './strict-hooks.svelte';
68
- export { defineResource, getContractFormFields } from './resource-contract';
70
+ export { defineResource, getContractFormFields, withContractProjection } from './resource-contract';
69
71
  export { UpdateManyPartialError } from './update-many-contract';
70
72
  export { CreateManyPartialError } from './create-many-contract';
71
73
  export type { ContractCreateOptions as UseCreateOptions } from './create-hooks.svelte';
@@ -75,6 +77,7 @@ export type { ContractUpdateParams as UseUpdateMutateParams } from './update-con
75
77
  export type { ContractDeleteOptions as UseDeleteOptions } from './delete-hooks.svelte';
76
78
  export type { ContractDeleteParams as UseDeleteMutateParams } from './delete-contract';
77
79
  export type { ContractFormAction, ContractFormDraft, ContractFormValues } from './resource-contract';
80
+ export type { ContractProjectionOptions } from './resource-contract';
78
81
  export type { ResourceContract, ContractSchemas, ContractRecord, ContractId, ContractInput } from './resource-contract';
79
82
  export type { ContractFilter, ContractSort } from './strict-hooks.svelte';
80
83
  export { defineCommand } from './command-contract';
@@ -114,6 +117,12 @@ export {
114
117
  export type { NotificationParams } from './notification.svelte';
115
118
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation, setI18nProvider, getI18nProvider, createI18nScope, provideI18nScope, getI18nScope, resetI18n } from './i18n.svelte';
116
119
  export type { I18nProvider, I18nScope, I18nScopeOptions } from './i18n.svelte';
120
+ export { buildPivot, buildPivotCacheKey, buildPivotDrilldown, buildPivotExportRequest, decodePivotResult, snapshotPivotQuery, snapshotPivotExportTaskResult, formatPivotValue, PIVOT_LIMITS } from './pivot';
121
+ export type { PivotAggregation, PivotCache, PivotDimension, PivotDimensionValue, PivotOptions, PivotPayload, PivotProvider, PivotQuery, PivotResult, PivotDrilldown, PivotExportRequest, PivotExportTaskResult } from './pivot';
122
+ export { civilDateTimeToInstant, parseDateTimeInstant, formatCivilDateTime } from './date-time';
123
+ export type { DateTimeValueMode, DateTimeDisambiguation } from './date-time';
124
+ export { validateGantt, GANTT_LIMITS } from './gantt';
125
+ export type { GanttTaskShape, GanttValidationError } from './gantt';
117
126
  export { audit, auditWithProvider, writeAuditEntry, recordMutationRollback, setAuditHandler, setAuditLogProvider, getAuditLogProvider, withValidatedAuditProvider, AuditError } from './audit';
118
127
  export type { AuditLogProvider, AuditLogTransport, AuditCreateParams, AuditQueryParams, AuditDraft } from './audit';
119
128
  export { TaskError } from './task-contract';
@@ -143,7 +152,17 @@ export type {
143
152
  WebhookSummary,
144
153
  } from './enterprise';
145
154
  export { assertEnterpriseRequestContext, createEnterpriseRequestContext } from './enterprise';
155
+ export {
156
+ DashboardContractError, decodeDashboardQuery, decodeDashboardSnapshot,
157
+ type DashboardMetric, type DashboardProvider, type DashboardQuery, type DashboardSnapshot, type DashboardWidget,
158
+ } from './dashboard-contract';
146
159
  export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider, defineAdminTool, decodeAdminToolArgs, executeAdminTool, projectAdminToolSchema } from './chatProvider.svelte';
160
+ export { ENTERPRISE_MATURITY, enterpriseMaturityFor } from './enterprise-maturity';
161
+ export type {
162
+ EnterpriseMaturityEntry,
163
+ EnterpriseMaturityStatus,
164
+ EnterpriseMaturityTier,
165
+ } from './enterprise-maturity';
147
166
  export type {
148
167
  ChatProvider, ChatMessage, ChatMessagePart, ChatContext, ChatAttachment, ChatSource,
149
168
  MessageStatus, ToolState, AgentProvider, AgentEvent, AgentOptions, ApprovalResponseOptions,
@@ -175,7 +194,7 @@ export type {
175
194
  DeleteManyParams, DeleteManyResult,
176
195
  Pagination, Sort, Filter, Identity,
177
196
  ResourceDefinition, ResourceProviderConfig, ResourceTransportConfig, ResourceAdapterConfig,
178
- FieldDefinition, MenuItem,
197
+ FieldDefinition, DateInputOptions, MenuItem,
179
198
  AuthActionResult, CheckResult,
180
199
  ResourceTypeMap, ResourceInputMap, ResourceInputOperation, InferResourceInput, KnownResources, InferData,
181
200
  BaseRecord, Role, AuditLog
@@ -190,9 +209,26 @@ export { createCaslAccessControl } from './adapters/casl';
190
209
  export { createCasbinAccessControl } from './adapters/casbin';
191
210
  export type { CasbinAdapterOptions } from './adapters/casbin';
192
211
  export { downloadData, toCsv, toJson, toXlsx, parseCSV } from './data-transfer.svelte';
212
+ export { parseSpreadsheetClipboard, serializeSpreadsheetClipboard } from './spreadsheet-clipboard';
213
+ export { parseSpreadsheetWorkbook, serializeSpreadsheetWorkbook, snapshotSpreadsheetWorkbook, SPREADSHEET_WORKBOOK_LIMITS } from './spreadsheet-workbook';
214
+ export type { SpreadsheetCellFormat, SpreadsheetSheet, SpreadsheetWorkbook } from './spreadsheet-workbook';
215
+ export { FILTER_COLLECTION_LIMIT, isNumericFilterField, isCollectionFilterOperator,
216
+ filterOperatorsForField, isFilterScalarValue, isFilterCollectionValue } from './filter-values';
217
+ export {
218
+ assertSchemaFormSchema, prepareSchemaFormValue, readSchemaFormPath,
219
+ matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
220
+ } from './schema-form';
221
+ export type {
222
+ SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
223
+ } from './schema-form';
193
224
  export { useExport } from './export-hooks.svelte';
225
+ export { snapshotExportTaskResult, downloadExportArtifact, safeArtifactUrl } from './export-format';
226
+ export type { ExportTaskResult } from './export-format';
194
227
  export type { UseExportOptions } from './export-hooks.svelte';
195
228
  export { useImport } from './import-hooks.svelte';
229
+ export type { ImportArtifact, ImportArtifactProvider, ImportTaskResult } from './import-contract';
230
+ export { snapshotImportArtifact, snapshotImportTaskResult } from './import-contract';
231
+ export { IMPORT_LIMITS } from './import-contract';
196
232
  export type { UseImportOptions, ImportProgress, ImportResult } from './import-hooks.svelte';
197
233
  export type { ExportFormat } from './data-transfer.svelte';
198
234
  export {
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
+ }
@@ -183,10 +183,15 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
183
183
  }
184
184
  });
185
185
  }
186
+ // 清理微任务只读取普通快照,不访问已经销毁或退出动画中的派生状态。
187
+ let lastQueryKey: QueryKey | undefined;
188
+ $effect(() => { lastQueryKey = options.queryKey; });
186
189
  $effect(() => () => {
187
190
  disposed = true;
191
+ const queryKey = lastQueryKey;
192
+ if (!queryKey) return;
188
193
  queueMicrotask(() => {
189
- const query = client.getQueryCache().find({ queryKey: options.queryKey, exact: true });
194
+ const query = client.getQueryCache().find({ queryKey, exact: true });
190
195
  if (query?.getObserversCount() === 0) void query.cancel();
191
196
  });
192
197
  });
@@ -2,8 +2,8 @@ import { CloneType, Kind, TransformKind, Type, type Static, type TObject, type T
2
2
  import { checkExact, createExactSchemaValidator } from './schema-validation';
3
3
  import { TypeGuard } from '@sinclair/typebox/type';
4
4
  import { DeleteManyPartialError, HttpError, type DataProvider, type KnownResources, type BaseRecord,
5
- type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
6
- type GetOneResult, type GetManyResult } from './types';
5
+ type GetListResult, type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
6
+ type GetOneParams, type GetOneResult, type GetManyResult } from './types';
7
7
  import { withResourceSchemas, createResourceRequestValidator, type ResourceSchemas } from './resource-schemas';
8
8
  import { decodeBaseRecord } from './record-decoder';
9
9
  import { snapshotPlainData } from './plain-data';
@@ -166,6 +166,115 @@ export function parseContractRecord(contract: ResourceContract, value: unknown):
166
166
  throw new HttpError('Invalid provider response', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' });
167
167
  }
168
168
 
169
+ export interface ContractProjectionOptions {
170
+ /** Supplies the id when the backend record has no declared contract id. */
171
+ readonly getId?: (input: {
172
+ resource: string;
173
+ requestedId: string | number | undefined;
174
+ record: unknown;
175
+ index: number;
176
+ }) => string | number | undefined;
177
+ }
178
+
179
+ function projectBySchema(schema: TSchema, value: unknown): unknown {
180
+ if (TypeGuard.IsObject(schema)) {
181
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
182
+ const source = value as Record<string, unknown>;
183
+ const projected: Record<string, unknown> = {};
184
+ for (const [key, property] of Object.entries(schema.properties)) {
185
+ if (Object.hasOwn(source, key)) projected[key] = projectBySchema(property, source[key]);
186
+ }
187
+ return projected;
188
+ }
189
+ if (TypeGuard.IsArray(schema)) {
190
+ return Array.isArray(value) ? value.map(item => projectBySchema(schema.items, item)) : value;
191
+ }
192
+ if (TypeGuard.IsTuple(schema)) {
193
+ return Array.isArray(value)
194
+ ? value.map((item, index) => schema.items?.[index] ? projectBySchema(schema.items[index], item) : item)
195
+ : value;
196
+ }
197
+ if (TypeGuard.IsUnion(schema)) {
198
+ const branch = schema.anyOf.find(candidate => checkExact(candidate, value));
199
+ return branch ? projectBySchema(branch, value) : value;
200
+ }
201
+ return value;
202
+ }
203
+
204
+ function projectRecord(
205
+ contract: ResourceContract,
206
+ resource: string,
207
+ record: unknown,
208
+ index: number,
209
+ requestedId: string | number | undefined,
210
+ options: ContractProjectionOptions,
211
+ ): unknown {
212
+ const projected = projectBySchema(definitionOf(contract).schemas.record, record);
213
+ if (typeof projected !== 'object' || projected === null || Array.isArray(projected)) return projected;
214
+ const result = projected as Record<string, unknown>;
215
+ if (!Object.hasOwn(result, 'id')) {
216
+ const id = options.getId?.({ resource, requestedId, record, index })
217
+ ?? requestedId
218
+ ?? (typeof record === 'object' && record !== null && !Array.isArray(record)
219
+ ? ['id', '_id', 'uuid', 'key', 'name'].map(key => (record as Record<string, unknown>)[key])
220
+ .find(candidate => typeof candidate === 'string' || typeof candidate === 'number')
221
+ : undefined)
222
+ ?? `${resource}:${index}`;
223
+ result['id'] = id;
224
+ }
225
+ return result;
226
+ }
227
+
228
+ /**
229
+ * Projects read responses into the exact closed shape required by resource contracts.
230
+ * Write responses remain untouched so mutation validation keeps its existing semantics.
231
+ */
232
+ export function withContractProjection(
233
+ provider: DataProvider,
234
+ contracts: Readonly<Record<string, ResourceContract>>,
235
+ options: ContractProjectionOptions = {},
236
+ ): Readonly<DataProvider> {
237
+ function contractFor(resource: string): ResourceContract | undefined {
238
+ return contracts[resource];
239
+ }
240
+ function projectList(resource: string, result: GetListResult): GetListResult {
241
+ const contract = contractFor(resource);
242
+ if (!contract) return result;
243
+ return {
244
+ ...result,
245
+ data: result.data.map((record, index) =>
246
+ projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
247
+ };
248
+ }
249
+ function projectMany(resource: string, result: GetManyResult): GetManyResult {
250
+ const contract = contractFor(resource);
251
+ if (!contract) return result;
252
+ return {
253
+ ...result,
254
+ data: result.data.map((record, index) =>
255
+ projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
256
+ };
257
+ }
258
+ function projectOne(params: GetOneParams, result: GetOneResult): GetOneResult {
259
+ const contract = contractFor(params.resource);
260
+ return contract
261
+ ? { ...result, data: projectRecord(contract, params.resource, result.data, 0, params.id, options) as BaseRecord }
262
+ : result;
263
+ }
264
+
265
+ const adapted: DataProvider = {
266
+ ...provider,
267
+ getList: async params => projectList(params.resource, await provider.getList(params)),
268
+ getOne: async params => projectOne(params, await provider.getOne(params)),
269
+ getApiUrl: () => provider.getApiUrl(),
270
+ };
271
+ if (provider.getMany) {
272
+ const getMany = provider.getMany.bind(provider);
273
+ adapted.getMany = async params => projectMany(params.resource, await getMany(params));
274
+ }
275
+ return Object.freeze(adapted);
276
+ }
277
+
169
278
  function idSchema(schemas: ContractSchemas): TSchema {
170
279
  const schema = schemas.record.properties['id'];
171
280
  if (!schema) throw new HttpError('Missing contract ID schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });