cabloy 5.1.248 → 5.1.250

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 (121) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/create-cabloy.yml +50 -3
  3. package/.vscode/settings.json +1 -2
  4. package/CHANGELOG.md +31 -0
  5. package/package.json +1 -7
  6. package/repo-agent-governance/managed-assets.json +12 -12
  7. package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +1 -1
  8. package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +1 -1
  9. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +2 -2
  10. package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +1 -1
  11. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +73 -22
  12. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +98 -7
  13. package/repo-docs/ai/playbook-spec-execution.md +1 -1
  14. package/repo-docs/backend/crud-workflow.md +92 -1
  15. package/repo-docs/backend/dto-guide.md +16 -1
  16. package/repo-docs/backend/dto-infer-generation.md +23 -5
  17. package/repo-docs/backend/orm-aggregate-group-guide.md +32 -0
  18. package/repo-docs/backend/validation-guide.md +38 -2
  19. package/repo-e2e/specs/cabloy-basic.spec.ts +9 -2
  20. package/vona/package.original.json +1 -7
  21. package/vona/packages-cli/cli/package.json +1 -1
  22. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/aggregate/src/dto/{{resourceName}}AggregateRes.tsx_ +11 -0
  23. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/src/lib/{{resourceName}}Analysis.ts_ +14 -0
  24. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/test/{{resourceName}}Analysis.test.ts_ +81 -0
  25. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/group/src/dto/{{resourceName}}GroupResItem.tsx_ +11 -0
  26. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
  27. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
  28. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
  29. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +4 -0
  30. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +2 -0
  31. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +2 -0
  32. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
  33. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
  34. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
  35. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/test/{{resourceName}}.test.ts_ +1 -1
  36. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/3-en-us.ts +2 -0
  37. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/4-zh-cn.ts +2 -0
  38. package/vona/packages-cli/cli-set-api/package.json +1 -1
  39. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudBasic.ts +2 -0
  40. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudStart.ts +2 -0
  41. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -1
  42. package/vona/packages-cli/cli-set-api/src/lib/crudAnalysis.ts +11 -0
  43. package/vona/packages-cli/cli-set-api/test/crudAnalysis.test.ts +385 -0
  44. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  45. package/vona/packages-vona/vona/package.json +1 -1
  46. package/vona/packages-vona/vona-core/package.json +1 -1
  47. package/vona/packages-vona/vona-mock/package.json +1 -1
  48. package/vona/pnpm-lock.yaml +76 -1008
  49. package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +18 -0
  50. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +2 -0
  51. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +2 -0
  52. package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +28 -1
  53. package/vona/src/suite/a-training/modules/training-student/src/dto/studentAggregateRes.tsx +11 -0
  54. package/vona/src/suite/a-training/modules/training-student/src/dto/studentGroupResItem.tsx +11 -0
  55. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectReq.tsx +2 -0
  56. package/vona/src/suite/a-training/modules/training-student/src/lib/studentAnalysis.ts +12 -0
  57. package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +22 -1
  58. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +4 -0
  59. package/vona/src/suite/a-training/modules/training-student/test/studentAnalysis.test.ts +422 -0
  60. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  61. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  62. package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
  63. package/vona/src/suite-vendor/a-captcha/package.json +1 -1
  64. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  65. package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
  66. package/vona/src/suite-vendor/a-vona/modules/a-locale/package.json +1 -1
  67. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  68. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
  69. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/utils.ts +1 -1
  70. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  71. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/.metadata/index.ts +26 -26
  72. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/aggregateResult.ts +53 -0
  73. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/controls.ts +163 -0
  74. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/execute.ts +49 -0
  75. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/index.ts +3 -0
  76. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/policy.ts +140 -0
  77. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_cache.ts +4 -2
  78. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +8 -3
  79. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dto.ts +3 -0
  80. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAggregate.ts +4 -1
  81. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAnalysis.ts +34 -0
  82. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/index.ts +1 -0
  83. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/analysis.ts +137 -0
  84. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/index.ts +1 -0
  85. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/relationsAggr.ts +1 -1
  86. package/vona/src/suite-vendor/a-vona/modules/a-orm/test/aggregateResult.test.ts +81 -0
  87. package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.test.ts +208 -0
  88. package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.types.test.ts +112 -0
  89. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  90. package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
  91. package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
  92. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  93. package/vona/src/suite-vendor/a-vona/modules/a-web/package.json +1 -1
  94. package/vona/src/suite-vendor/a-vona/modules/a-web/src/.metadata/index.ts +40 -40
  95. package/vona/src/suite-vendor/a-vona/modules/a-web/src/bean/pipe.filter.ts +44 -3
  96. package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/analysisFilter.ts +69 -0
  97. package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/decorator/arguments.ts +6 -4
  98. package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/index.ts +1 -0
  99. package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/dto.ts +2 -1
  100. package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/filter.ts +7 -2
  101. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  102. package/zova/package.original.json +0 -6
  103. package/zova/packages-cli/cli/package.json +2 -2
  104. package/zova/packages-cli/cli-set-front/package.json +2 -2
  105. package/zova/packages-utils/zova-jsx/package.json +2 -2
  106. package/zova/packages-utils/zova-vite/package.json +1 -1
  107. package/zova/packages-zova/zova/package.json +3 -3
  108. package/zova/packages-zova/zova-core/package.json +1 -1
  109. package/zova/pnpm-lock.yaml +11 -963
  110. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +4 -1
  111. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx +2 -2
  112. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +16 -2
  113. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +201 -7
  114. package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +42 -0
  115. package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +10 -0
  116. package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
  117. package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/package.json +1 -1
  118. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  119. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  120. package/zova/src/suite-vendor/a-zova/package.json +5 -5
  121. package/vona/.textlintrc.json +0 -19
@@ -0,0 +1,163 @@
1
+ import z from 'zod';
2
+
3
+ import type {
4
+ IAnalysisCapabilities,
5
+ IAnalysisPolicy,
6
+ IAnalysisSelection,
7
+ IResolvedAnalysis,
8
+ TypeAnalysisFunction,
9
+ TypeAnalysisMode,
10
+ } from '../../types/analysis.ts';
11
+
12
+ export const analysisFunctions = ['count', 'sum', 'avg', 'min', 'max'] as const;
13
+ export const analysisReservedFields = new Set([
14
+ 'groups',
15
+ 'aggrs',
16
+ 'where',
17
+ 'joins',
18
+ 'having',
19
+ 'columns',
20
+ 'orders',
21
+ 'limit',
22
+ 'offset',
23
+ 'pageNo',
24
+ 'pageSize',
25
+ 'distinct',
26
+ 'include',
27
+ 'with',
28
+ ]);
29
+
30
+ /** A deliberate validation/resource-bound error, also usable without an application context. */
31
+ export class AnalysisError extends Error {
32
+ readonly code = 422;
33
+ readonly status = 422;
34
+ }
35
+
36
+ export function analysisFailure(message: string): never {
37
+ throw new AnalysisError(message);
38
+ }
39
+
40
+ function actionDefaults(policy: IAnalysisPolicy, mode: TypeAnalysisMode): IAnalysisSelection {
41
+ if (mode !== 'group' && mode !== 'aggregate') analysisFailure('Unknown analysis action');
42
+ const defaults = policy.defaults[mode];
43
+ if (!defaults) analysisFailure(`Analysis ${mode} action is not enabled`);
44
+ return defaults;
45
+ }
46
+
47
+ function columnArray(columns: readonly string[], max: number) {
48
+ return z
49
+ .array(z.enum([...columns]))
50
+ .min(1)
51
+ .max(max)
52
+ .superRefine((value, ctx) => {
53
+ if (new Set(value).size !== value.length) {
54
+ ctx.addIssue({ code: 'custom', message: 'Duplicate analysis columns are not allowed' });
55
+ }
56
+ });
57
+ }
58
+
59
+ /** Native array/object schemas preserve finite enums for query coercion and OpenAPI. */
60
+ export function analysisControlSchemas(policy: IAnalysisPolicy, mode: TypeAnalysisMode) {
61
+ const defaults = actionDefaults(policy, mode);
62
+ const shape: Partial<Record<TypeAnalysisFunction, z.ZodType>> = {};
63
+ for (const fn of analysisFunctions) {
64
+ const columns = policy.aggrs[fn];
65
+ if (columns) shape[fn] = columnArray(columns, policy.limits.maxMetrics).optional();
66
+ }
67
+ const aggrs = z
68
+ .strictObject(shape)
69
+ .superRefine((value, ctx) => {
70
+ const count = Object.values(value).reduce<number>(
71
+ (total, columns) => total + (Array.isArray(columns) ? columns.length : 0),
72
+ 0,
73
+ );
74
+ if (count === 0 || count > policy.limits.maxMetrics) {
75
+ ctx.addIssue({
76
+ code: 'custom',
77
+ message: `Analysis requires 1 to ${policy.limits.maxMetrics} metrics`,
78
+ });
79
+ }
80
+ })
81
+ .default(() =>
82
+ Object.fromEntries(
83
+ Object.entries(defaults.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
84
+ ),
85
+ );
86
+ const groups =
87
+ mode === 'group'
88
+ ? columnArray(policy.groups, policy.limits.maxGroups).default(() => [...defaults.groups!])
89
+ : undefined;
90
+ const selection = z.strictObject({ groups: groups ?? z.never().optional(), aggrs });
91
+ return { groups, aggrs, selection };
92
+ }
93
+
94
+ /** Validate controls independently of row filters and ignored list controls. */
95
+ export function resolveAnalysis(
96
+ policy: IAnalysisPolicy,
97
+ mode: TypeAnalysisMode,
98
+ input?: unknown,
99
+ ): IResolvedAnalysis {
100
+ if (input !== undefined && (!input || typeof input !== 'object' || Array.isArray(input))) {
101
+ analysisFailure('Analysis parameters must be an object');
102
+ }
103
+ const params = (input ?? {}) as Record<string, unknown>;
104
+ for (const key of ['joins', 'having', 'distinct', 'include', 'with']) {
105
+ if (Object.hasOwn(params, key) && params[key] !== undefined) {
106
+ analysisFailure(`Analysis does not support ${key}`);
107
+ }
108
+ }
109
+ if (mode === 'aggregate' && Object.hasOwn(params, 'groups')) {
110
+ analysisFailure('Aggregate analysis does not support groups');
111
+ }
112
+ // The request context can enable generic query coercion. Direct service calls must
113
+ // still supply native arrays/objects, never JSON strings or coerced scalar items.
114
+ const assertNativeColumns = (columns: unknown) => {
115
+ if (!Array.isArray(columns) || columns.some(column => typeof column !== 'string')) {
116
+ analysisFailure('Analysis columns must be arrays of strings');
117
+ }
118
+ };
119
+ if (params.groups !== undefined) assertNativeColumns(params.groups);
120
+ if (params.aggrs !== undefined) {
121
+ if (!params.aggrs || typeof params.aggrs !== 'object' || Array.isArray(params.aggrs)) {
122
+ analysisFailure('Analysis metrics must be an object of column arrays');
123
+ }
124
+ for (const columns of Object.values(params.aggrs)) assertNativeColumns(columns);
125
+ }
126
+ const parsed = analysisControlSchemas(policy, mode).selection.safeParse({
127
+ ...(mode === 'group' ? { groups: params.groups } : {}),
128
+ aggrs: params.aggrs,
129
+ });
130
+ if (!parsed.success) analysisFailure(parsed.error.message);
131
+ const result: IResolvedAnalysis = { aggrs: {} };
132
+ if (mode === 'group') {
133
+ // Canonical policy order also stabilizes SQL/cache keys for equivalent selections.
134
+ const selected = parsed.data.groups as string[];
135
+ result.groups = policy.groups.filter(column => selected.includes(column));
136
+ }
137
+ for (const fn of analysisFunctions) {
138
+ const selected = parsed.data.aggrs[fn] as string[] | undefined;
139
+ if (selected) result.aggrs[fn] = policy.aggrs[fn]!.filter(column => selected.includes(column));
140
+ }
141
+ return result;
142
+ }
143
+
144
+ export function analysisCapabilities(
145
+ policy: IAnalysisPolicy,
146
+ mode: TypeAnalysisMode,
147
+ ): IAnalysisCapabilities {
148
+ const defaults = actionDefaults(policy, mode);
149
+ return {
150
+ mode,
151
+ groups: mode === 'group' ? [...policy.groups] : [],
152
+ aggrs: Object.fromEntries(
153
+ Object.entries(policy.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
154
+ ),
155
+ defaults: {
156
+ ...(mode === 'group' ? { groups: [...defaults.groups!] } : {}),
157
+ aggrs: Object.fromEntries(
158
+ Object.entries(defaults.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
159
+ ),
160
+ },
161
+ limits: { ...policy.limits },
162
+ };
163
+ }
@@ -0,0 +1,49 @@
1
+ import type {
2
+ IAnalysisPolicy,
3
+ TypeAnalysisAggregateParams,
4
+ TypeAnalysisAggregateResult,
5
+ TypeAnalysisGroupParams,
6
+ TypeAnalysisGroupResult,
7
+ } from '../../types/analysis.ts';
8
+ import type { TypeModelOfModelLike } from '../../types/relations.ts';
9
+ import type { BeanModelCache } from '../bean.model/bean.model_cache.ts';
10
+
11
+ import { analysisFailure, resolveAnalysis } from './controls.ts';
12
+
13
+ function assertModel(model: object, policy: IAnalysisPolicy) {
14
+ if (!(model instanceof policy.modelClass))
15
+ analysisFailure('Analysis policy does not belong to this model');
16
+ }
17
+
18
+ /** Keep instance/soft-delete scope, caching and scalar normalization owned by the ORM. */
19
+ export async function $executeAnalysisGroup<Policy extends IAnalysisPolicy>(
20
+ model: TypeModelOfModelLike<Policy['modelLike']>,
21
+ policy: Policy,
22
+ params?: TypeAnalysisGroupParams<Policy>,
23
+ ): Promise<TypeAnalysisGroupResult<Policy>[]> {
24
+ assertModel(model, policy);
25
+ const { groups, aggrs } = resolveAnalysis(policy, 'group', params);
26
+ const rows = await (model as unknown as BeanModelCache<any>).group({
27
+ where: params?.where,
28
+ groups: groups!,
29
+ aggrs,
30
+ orders: groups!.map(column => [column, 'asc']),
31
+ limit: policy.limits.maxGroupRows + 1,
32
+ });
33
+ if (rows.length > policy.limits.maxGroupRows)
34
+ analysisFailure(`Analysis exceeds ${policy.limits.maxGroupRows} group rows`);
35
+ return rows as TypeAnalysisGroupResult<Policy>[];
36
+ }
37
+
38
+ export async function $executeAnalysisAggregate<Policy extends IAnalysisPolicy>(
39
+ model: TypeModelOfModelLike<Policy['modelLike']>,
40
+ policy: Policy,
41
+ params?: TypeAnalysisAggregateParams<Policy>,
42
+ ): Promise<TypeAnalysisAggregateResult<Policy>> {
43
+ assertModel(model, policy);
44
+ const { aggrs } = resolveAnalysis(policy, 'aggregate', params);
45
+ return (await (model as unknown as BeanModelCache<any>).aggregate({
46
+ where: params?.where,
47
+ aggrs,
48
+ })) as TypeAnalysisAggregateResult<Policy>;
49
+ }
@@ -0,0 +1,3 @@
1
+ export * from './controls.ts';
2
+ export * from './execute.ts';
3
+ export * from './policy.ts';
@@ -0,0 +1,140 @@
1
+ import { ZodMetadata } from '@cabloy/zod-openapi';
2
+ import { getTargetDecoratorRules } from 'vona-module-a-openapiutils';
3
+
4
+ import type {
5
+ IAnalysisConfig,
6
+ IAnalysisPolicy,
7
+ TypeAnalysisAggrs,
8
+ TypeAnalysisAllowedAggrs,
9
+ TypeAnalysisEntity,
10
+ TypeAnalysisField,
11
+ TypeAnalysisFunction,
12
+ TypeAnalysisModelLike,
13
+ TypeAnalysisPolicy,
14
+ } from '../../types/analysis.ts';
15
+
16
+ import { getClassEntityFromClassModel, prepareClassModel } from '../../common/utils.ts';
17
+ import { analysisFunctions, analysisReservedFields, resolveAnalysis } from './controls.ts';
18
+
19
+ function invalid(message: string): never {
20
+ throw new TypeError(`Invalid analysis policy: ${message}`);
21
+ }
22
+
23
+ function assertColumns(
24
+ columns: unknown,
25
+ label: string,
26
+ allowEmpty = false,
27
+ ): asserts columns is string[] {
28
+ if (!Array.isArray(columns) || (!allowEmpty && columns.length === 0))
29
+ invalid(`${label} must be a nonempty column array`);
30
+ if (columns.some(column => typeof column !== 'string'))
31
+ invalid(`${label} columns must be strings`);
32
+ if (new Set(columns).size !== columns.length) invalid(`${label} contains duplicate columns`);
33
+ }
34
+
35
+ function copyAggrs(aggrs: TypeAnalysisAggrs): TypeAnalysisAggrs {
36
+ return Object.freeze(
37
+ Object.fromEntries(
38
+ Object.entries(aggrs).map(([fn, columns]) => [fn, Object.freeze([...columns!])]),
39
+ ),
40
+ );
41
+ }
42
+
43
+ /** Static model/entity metadata only: safe while DTO classes are being evaluated. */
44
+ export function $defineAnalysis<
45
+ ModelLike extends TypeAnalysisModelLike,
46
+ const Groups extends readonly TypeAnalysisField<TypeAnalysisEntity<ModelLike>>[],
47
+ const Aggrs extends TypeAnalysisAllowedAggrs<TypeAnalysisEntity<ModelLike>>,
48
+ >(
49
+ modelLike: ModelLike,
50
+ config: IAnalysisConfig<Groups, Aggrs> & {
51
+ aggrs: Aggrs & Record<Exclude<keyof Aggrs, TypeAnalysisFunction>, never>;
52
+ },
53
+ ): TypeAnalysisPolicy<ModelLike, Groups, Aggrs> {
54
+ const modelClass = prepareClassModel(modelLike as any);
55
+ const entityClass = getClassEntityFromClassModel(modelClass);
56
+ const rules = getTargetDecoratorRules(entityClass.prototype, true);
57
+ assertColumns(config.groups, 'groups', true);
58
+ const validateField = (column: string, numeric: boolean) => {
59
+ if (!/^[A-Z_]\w*$/i.test(column) || analysisReservedFields.has(column))
60
+ invalid(`unsupported column ${column}`);
61
+ if (!Object.hasOwn(rules, column)) invalid(`unknown entity column ${column}`);
62
+ const schema = ZodMetadata.unwrapChained(ZodMetadata.resolveLazySchema(rules[column]!));
63
+ if (numeric && (column === 'id' || column === 'iid' || schema.def.type !== 'number'))
64
+ invalid(`non-count metrics require numeric columns: ${column}`);
65
+ };
66
+ for (const column of config.groups) validateField(column, false);
67
+ if (!config.aggrs || typeof config.aggrs !== 'object' || Array.isArray(config.aggrs))
68
+ invalid('aggrs must be an object');
69
+ const aliases = new Set<string>(config.groups);
70
+ let metricCount = 0;
71
+ for (const [fn, columns] of Object.entries(config.aggrs)) {
72
+ if (!analysisFunctions.includes(fn as TypeAnalysisFunction))
73
+ invalid(`unsupported function ${fn}`);
74
+ assertColumns(columns, fn);
75
+ for (const column of columns) {
76
+ if (column !== '*' || fn !== 'count') validateField(column, fn !== 'count');
77
+ const alias = `${fn}_${column === '*' ? 'all' : column}`;
78
+ if (aliases.has(alias) || analysisReservedFields.has(alias))
79
+ invalid(`alias collision: ${alias}`);
80
+ aliases.add(alias);
81
+ metricCount++;
82
+ }
83
+ }
84
+ if (metricCount === 0) invalid('at least one allowed metric is required');
85
+ const limits = { maxGroups: 3, maxMetrics: 10, maxGroupRows: 1000, ...config.limits };
86
+ for (const [key, value] of Object.entries(limits)) {
87
+ if (
88
+ !['maxGroups', 'maxMetrics', 'maxGroupRows'].includes(key) ||
89
+ !Number.isSafeInteger(value) ||
90
+ value < 1 ||
91
+ value >= Number.MAX_SAFE_INTEGER
92
+ ) {
93
+ invalid(`invalid limit ${key}`);
94
+ }
95
+ }
96
+ if (
97
+ !config.defaults ||
98
+ Object.keys(config.defaults).some(key => key !== 'group' && key !== 'aggregate')
99
+ ) {
100
+ invalid('unknown or missing action defaults');
101
+ }
102
+ if (!config.defaults.group && !config.defaults.aggregate)
103
+ invalid('at least one action default is required');
104
+ const policy: IAnalysisPolicy = {
105
+ modelLike,
106
+ modelClass,
107
+ groups: Object.freeze([...config.groups]),
108
+ aggrs: copyAggrs(config.aggrs),
109
+ defaults: {},
110
+ limits: Object.freeze(limits),
111
+ };
112
+ const defaults: {
113
+ group?: IAnalysisPolicy['defaults']['group'];
114
+ aggregate?: IAnalysisPolicy['defaults']['aggregate'];
115
+ } = {};
116
+ for (const mode of ['group', 'aggregate'] as const) {
117
+ const input = config.defaults[mode];
118
+ if (!input) continue;
119
+ // Do not allow omitted defaults to expand authorization implicitly.
120
+ if (!Object.hasOwn(input, 'aggrs') || (mode === 'group' && !Object.hasOwn(input, 'groups')))
121
+ invalid(`incomplete ${mode} defaults`);
122
+ if (Object.keys(input).some(key => key !== 'aggrs' && !(mode === 'group' && key === 'groups')))
123
+ invalid(`unsupported ${mode} default control`);
124
+ const forValidation = { ...policy, defaults: { [mode]: input } } as IAnalysisPolicy;
125
+ try {
126
+ const resolved = resolveAnalysis(forValidation, mode, input);
127
+ const aggrs = copyAggrs(resolved.aggrs);
128
+ if (mode === 'group')
129
+ defaults.group = Object.freeze({ groups: Object.freeze(resolved.groups!), aggrs });
130
+ else defaults.aggregate = Object.freeze({ aggrs });
131
+ } catch (error) {
132
+ invalid(`${mode} defaults: ${(error as Error).message}`);
133
+ }
134
+ }
135
+ return Object.freeze({ ...policy, defaults: Object.freeze(defaults) }) as TypeAnalysisPolicy<
136
+ ModelLike,
137
+ Groups,
138
+ Aggrs
139
+ >;
140
+ }
@@ -20,6 +20,7 @@ import type {
20
20
  IModelSelectAggrParams,
21
21
  IModelSelectCountParams,
22
22
  IModelSelectGroupParams,
23
+ IModelSelectGeneralParams,
23
24
  IModelSelectParams,
24
25
  IModelUpdateOptions,
25
26
  ITableRecord,
@@ -34,6 +35,7 @@ import type {
34
35
  } from '../../types/index.ts';
35
36
 
36
37
  import { getTargetColumnName } from '../../common/utils.ts';
38
+ import { normalizeAggregateRows } from '../../lib/aggregateResult.ts';
37
39
  import { getCacheModelsClear } from '../../lib/const.ts';
38
40
  import { ServiceCacheEntity } from '../../service/cacheEntity_.ts';
39
41
  import { ServiceCacheQuery } from '../../service/cacheQuery_.ts';
@@ -425,7 +427,7 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
425
427
 
426
428
  private async __select_cache(
427
429
  table: keyof ITableRecord,
428
- params?: IModelSelectParams<TRecord>,
430
+ params?: IModelSelectParams<TRecord> & Pick<IModelSelectGeneralParams<TRecord>, 'aggrs'>,
429
431
  options?: IModelMethodOptions,
430
432
  ): Promise<TRecord[]> {
431
433
  // check if cache
@@ -445,7 +447,7 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
445
447
  },
446
448
  db: this.db,
447
449
  });
448
- return items;
450
+ return normalizeAggregateRows(items, params?.aggrs);
449
451
  }
450
452
 
451
453
  async get<T extends IModelGetOptions<TRecord>>(
@@ -18,6 +18,7 @@ import type {
18
18
  TypeModelWhere,
19
19
  } from '../../types/index.ts';
20
20
 
21
+ import { normalizeAggregateRows, normalizeAggregateValue } from '../aggregateResult.ts';
21
22
  import { BeanModelView } from './bean.model_view.ts';
22
23
 
23
24
  export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecord> {
@@ -66,7 +67,8 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
66
67
  }
67
68
 
68
69
  protected async _select<
69
- T extends IModelSelectParams<TRecord> | IBuildModelSelectParams<TRecord, undefined, never>,
70
+ T extends (IModelSelectParams<TRecord> | IBuildModelSelectParams<TRecord, undefined, never>) &
71
+ Pick<IModelSelectGeneralParams<TRecord>, 'aggrs'>,
70
72
  >(
71
73
  table?: keyof ITableRecord,
72
74
  params?: T,
@@ -89,7 +91,10 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
89
91
  }
90
92
  return `model.select: ${tip}, ${builder.toQuery()}`;
91
93
  });
92
- return (await this.dialect.select(builder, datas, () => this.columns(table))) as TRecord[];
94
+ const rows = (await this.dialect.select(builder, datas, () =>
95
+ this.columns(table),
96
+ )) as TRecord[];
97
+ return normalizeAggregateRows(rows, params?.aggrs);
93
98
  }
94
99
 
95
100
  protected _select_buildParams<T extends IModelSelectGeneralParams<TRecord>>(
@@ -164,7 +169,7 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
164
169
  // ready
165
170
  this.$loggerChild('model').debug(() => `model.count: ${builder.toQuery()}`);
166
171
  const res = await builder;
167
- return this.extractFirstNumber(res);
172
+ return normalizeAggregateValue(this.extractFirstValue(res), 'count');
168
173
  }
169
174
 
170
175
  protected async _increment(
@@ -1,4 +1,5 @@
1
1
  import { DtoAggregate } from './dtoAggregate.ts';
2
+ import { DtoAnalysisAggregate, DtoAnalysisGroup } from './dtoAnalysis.ts';
2
3
  import { DtoCreate } from './dtoCreate.ts';
3
4
  import { DtoGet } from './dtoGet.ts';
4
5
  import { DtoGroup } from './dtoGroup.ts';
@@ -15,6 +16,8 @@ export const $Dto = {
15
16
  mutate: DtoMutate,
16
17
  get: DtoGet,
17
18
  aggregate: DtoAggregate,
19
+ analysisGroup: DtoAnalysisGroup,
20
+ analysisAggregate: DtoAnalysisAggregate,
18
21
  group: DtoGroup,
19
22
  query: DtoQuery,
20
23
  queryPage: DtoQueryPage,
@@ -56,7 +56,10 @@ export function _DtoAggregate_inner<
56
56
  if (!columns) continue;
57
57
  for (const column of columns) {
58
58
  const column2 = `${key}_${column === '*' ? 'all' : column}`;
59
- Api.field(v.optional(), z.string())(classTarget.prototype, column2);
59
+ Api.field(v.optional(), key === 'count' ? z.string() : z.string().nullable())(
60
+ classTarget.prototype,
61
+ column2,
62
+ );
60
63
  }
61
64
  }
62
65
  return classTarget as any;
@@ -0,0 +1,34 @@
1
+ import type { Constructable } from 'vona';
2
+
3
+ import { $Class } from 'vona';
4
+
5
+ import type {
6
+ IAnalysisPolicy,
7
+ TypeAnalysisAggregateResult,
8
+ TypeAnalysisGroupResult,
9
+ } from '../../types/analysis.ts';
10
+
11
+ import { _DtoAggregate_inner } from './dtoAggregate.ts';
12
+ import { DtoGroup } from './dtoGroup.ts';
13
+
14
+ /** A finite optional superset: absent fields are unrequested, null retains SQL meaning. */
15
+ export function DtoAnalysisGroup<Policy extends IAnalysisPolicy>(
16
+ policy: Policy,
17
+ ): Constructable<TypeAnalysisGroupResult<Policy>> {
18
+ if (!policy.defaults.group) throw new TypeError('Analysis group action is not enabled');
19
+ const base = DtoGroup(policy.modelLike as any, [...policy.groups] as any, policy.aggrs as any);
20
+ return $Class.partial(base) as Constructable<TypeAnalysisGroupResult<Policy>>;
21
+ }
22
+
23
+ export function DtoAnalysisAggregate<Policy extends IAnalysisPolicy>(
24
+ policy: Policy,
25
+ ): Constructable<TypeAnalysisAggregateResult<Policy>> {
26
+ if (!policy.defaults.aggregate) throw new TypeError('Analysis aggregate action is not enabled');
27
+ abstract class TargetClass {}
28
+ // Aggregate aliases deliberately do not copy field validators/renderers from source columns.
29
+ return _DtoAggregate_inner(
30
+ TargetClass as any,
31
+ policy.modelLike as any,
32
+ policy.aggrs as any,
33
+ ) as Constructable<TypeAnalysisAggregateResult<Policy>>;
34
+ }
@@ -1,3 +1,4 @@
1
+ export * from './analysis/index.ts';
1
2
  export * from './const.ts';
2
3
  export * from './databaseDialect.ts';
3
4
  export * from './dto/index.ts';
@@ -0,0 +1,137 @@
1
+ import type { Constructable } from 'vona';
2
+
3
+ import type { BeanModelMeta } from '../lib/bean.model/bean.model_meta.ts';
4
+ import type { TypeModelWhere } from './modelWhere.ts';
5
+ import type { IModelClassRecord } from './onion/model.ts';
6
+ import type { TypeModelOfModelLike, TypeSymbolKeyEntity } from './relations.ts';
7
+
8
+ export type TypeAnalysisMode = 'group' | 'aggregate';
9
+ export type TypeAnalysisFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
10
+ export type TypeAnalysisModelLike =
11
+ | Constructable<BeanModelMeta>
12
+ | (() => Constructable<BeanModelMeta>)
13
+ | keyof IModelClassRecord;
14
+ export type TypeAnalysisEntity<ModelLike extends TypeAnalysisModelLike> =
15
+ TypeModelOfModelLike<ModelLike>[TypeSymbolKeyEntity];
16
+ export type TypeAnalysisField<Entity> = Extract<keyof Entity, string>;
17
+ export type TypeAnalysisNumericField<Entity> = Exclude<
18
+ {
19
+ [K in keyof Entity]-?: NonNullable<Entity[K]> extends number ? K : never;
20
+ }[keyof Entity],
21
+ 'id' | 'iid'
22
+ > &
23
+ string;
24
+
25
+ export type TypeAnalysisAggrs = Partial<Record<TypeAnalysisFunction, readonly string[]>>;
26
+ export type TypeAnalysisAllowedAggrs<Entity> = {
27
+ readonly count?: readonly ('*' | TypeAnalysisField<Entity>)[];
28
+ } & {
29
+ readonly [
30
+ K in Exclude<TypeAnalysisFunction, 'count'>
31
+ ]?: readonly TypeAnalysisNumericField<Entity>[];
32
+ };
33
+ export type TypeAnalysisSelectionAggrs<Aggrs extends TypeAnalysisAggrs> = {
34
+ readonly [K in keyof Aggrs]?: NonNullable<Aggrs[K]> extends readonly string[]
35
+ ? readonly NonNullable<Aggrs[K]>[number][]
36
+ : never;
37
+ };
38
+ export interface IAnalysisLimits {
39
+ readonly maxGroups: number;
40
+ readonly maxMetrics: number;
41
+ readonly maxGroupRows: number;
42
+ }
43
+ export interface IAnalysisSelection {
44
+ readonly groups?: readonly string[];
45
+ readonly aggrs: TypeAnalysisAggrs;
46
+ }
47
+
48
+ /** Structural server-owned policy surface; model references must not be serialized. */
49
+ export interface IAnalysisPolicy {
50
+ readonly modelLike: TypeAnalysisModelLike;
51
+ readonly modelClass: Constructable<BeanModelMeta>;
52
+ readonly groups: readonly string[];
53
+ readonly aggrs: TypeAnalysisAggrs;
54
+ readonly defaults: {
55
+ readonly group?: IAnalysisSelection & { readonly groups: readonly string[] };
56
+ readonly aggregate?: { readonly aggrs: TypeAnalysisAggrs };
57
+ };
58
+ readonly limits: IAnalysisLimits;
59
+ }
60
+ export type TypeAnalysisPolicy<
61
+ ModelLike extends TypeAnalysisModelLike,
62
+ Groups extends readonly string[],
63
+ Aggrs extends TypeAnalysisAggrs,
64
+ > = Omit<IAnalysisPolicy, 'modelLike' | 'groups' | 'aggrs'> & {
65
+ readonly modelLike: ModelLike;
66
+ readonly groups: Groups;
67
+ readonly aggrs: Aggrs;
68
+ };
69
+ export interface IAnalysisConfig<
70
+ Groups extends readonly string[],
71
+ Aggrs extends TypeAnalysisAggrs,
72
+ > {
73
+ readonly groups: Groups;
74
+ readonly aggrs: Aggrs;
75
+ readonly defaults: {
76
+ readonly group?: {
77
+ readonly groups: readonly NoInfer<Groups[number]>[];
78
+ readonly aggrs: TypeAnalysisSelectionAggrs<NoInfer<Aggrs>>;
79
+ };
80
+ readonly aggregate?: { readonly aggrs: TypeAnalysisSelectionAggrs<NoInfer<Aggrs>> };
81
+ };
82
+ readonly limits?: Partial<IAnalysisLimits>;
83
+ }
84
+
85
+ interface IAnalysisParams<Policy extends IAnalysisPolicy> {
86
+ where?: TypeModelWhere<TypeAnalysisEntity<Policy['modelLike']>>;
87
+ aggrs?: TypeAnalysisSelectionAggrs<Policy['aggrs']>;
88
+ // Existing list controls remain accepted but never drive analysis execution.
89
+ columns?: unknown;
90
+ orders?: unknown;
91
+ limit?: number;
92
+ offset?: number;
93
+ pageNo?: number;
94
+ pageSize?: number;
95
+ joins?: never;
96
+ having?: never;
97
+ distinct?: never;
98
+ include?: never;
99
+ with?: never;
100
+ }
101
+ export type TypeAnalysisGroupParams<Policy extends IAnalysisPolicy> = IAnalysisParams<Policy> & {
102
+ groups?: readonly Policy['groups'][number][];
103
+ };
104
+ export type TypeAnalysisAggregateParams<Policy extends IAnalysisPolicy> =
105
+ IAnalysisParams<Policy> & {
106
+ groups?: never;
107
+ };
108
+
109
+ type TypeAnalysisAlias<Column extends string> = Column extends '*' ? 'all' : Column;
110
+ export type TypeAnalysisAggregateResult<Policy extends IAnalysisPolicy> = {
111
+ -readonly [
112
+ Fn in keyof Policy['aggrs'] &
113
+ TypeAnalysisFunction as Policy['aggrs'][Fn] extends readonly string[]
114
+ ? `${Fn}_${TypeAnalysisAlias<Policy['aggrs'][Fn][number]>}`
115
+ : never
116
+ ]?: Fn extends 'count' ? string : string | null;
117
+ };
118
+ export type TypeAnalysisGroupResult<Policy extends IAnalysisPolicy> = Partial<
119
+ Pick<
120
+ TypeAnalysisEntity<Policy['modelLike']>,
121
+ Extract<Policy['groups'][number], keyof TypeAnalysisEntity<Policy['modelLike']>>
122
+ >
123
+ > &
124
+ TypeAnalysisAggregateResult<Policy>;
125
+
126
+ export interface IAnalysisCapabilities {
127
+ readonly mode: TypeAnalysisMode;
128
+ readonly groups: readonly string[];
129
+ readonly aggrs: TypeAnalysisAggrs;
130
+ readonly defaults: IAnalysisSelection;
131
+ readonly limits: IAnalysisLimits;
132
+ }
133
+
134
+ export interface IResolvedAnalysis {
135
+ groups?: string[];
136
+ aggrs: Partial<Record<TypeAnalysisFunction, string[]>>;
137
+ }
@@ -1,3 +1,4 @@
1
+ export * from './analysis.ts';
1
2
  export * from './columns.ts';
2
3
  export * from './config.ts';
3
4
  export * from './database.ts';
@@ -27,7 +27,7 @@ export type TypeUtilAggrPrepareColumns<TColumns> = TColumns extends string[]
27
27
  : undefined;
28
28
  export type TypeRecordAggrsValues<TRecord extends Record<string, any>> = TRecord[keyof TRecord];
29
29
  export type TypeRecordAggrsValuesToObject<AggrValues extends string> = {
30
- [K in AggrValues]: string | undefined;
30
+ [K in AggrValues]: K extends `count_${string}` ? string | undefined : string | null | undefined;
31
31
  };
32
32
 
33
33
  export type TypeUtilGetAggrsFromRelationAndIncludeWrapper<