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,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { normalizeAggregateRows, normalizeAggregateValue } from '../src/lib/aggregateResult.ts';
5
+
6
+ describe('ORM aggregate result adaptation', () => {
7
+ it('preserves driver strings and converts supported numeric scalars', () => {
8
+ for (const fn of ['count', 'sum', 'avg', 'min', 'max'] as const) {
9
+ for (const value of [0, 17, 17n, Number.MAX_SAFE_INTEGER, '900719925474099312345']) {
10
+ assert.equal(normalizeAggregateValue(value, fn), String(value));
11
+ }
12
+ assert.equal(normalizeAggregateValue(undefined, fn), undefined);
13
+ for (const value of [true, {}, new Date(), Number.NaN, Infinity, -Infinity]) {
14
+ assert.throws(() => normalizeAggregateValue(value, fn), TypeError);
15
+ }
16
+ }
17
+ for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
18
+ for (const value of ['001.2300', '-12345678901234567890.12345', '1e-100', -1.25, -1n]) {
19
+ assert.equal(normalizeAggregateValue(value, fn), String(value));
20
+ }
21
+ assert.equal(normalizeAggregateValue(null, fn), null);
22
+ }
23
+ });
24
+
25
+ it('rejects invalid and already lossy count values', () => {
26
+ for (const value of [
27
+ null,
28
+ -1,
29
+ -1n,
30
+ 1.5,
31
+ Number.MAX_SAFE_INTEGER + 1,
32
+ '',
33
+ '01',
34
+ '-1',
35
+ '+1',
36
+ ' 1',
37
+ '1 ',
38
+ '1.0',
39
+ '1e3',
40
+ ]) {
41
+ assert.throws(() => normalizeAggregateValue(value, 'count'), TypeError);
42
+ }
43
+ });
44
+
45
+ it('adapts only requested aliases without mutating cached rows', () => {
46
+ const unchanged = Object.freeze({ level: 2, count_all: '2', sum_age: null });
47
+ const legacy = Object.freeze({
48
+ level: 1,
49
+ count_all: 2,
50
+ avg_age: 1.25,
51
+ sum_age: null,
52
+ count_name: 7,
53
+ });
54
+ const rows = [unchanged, legacy];
55
+ const result = normalizeAggregateRows(rows, { count: '*', sum: 'age', avg: 'age' });
56
+ assert.notEqual(result, rows);
57
+ assert.equal(result[0], unchanged);
58
+ assert.notEqual(result[1], legacy);
59
+ assert.deepEqual(result[1], {
60
+ level: 1,
61
+ count_all: '2',
62
+ avg_age: '1.25',
63
+ sum_age: null,
64
+ count_name: 7,
65
+ });
66
+ assert.equal(legacy.count_all, 2);
67
+ assert.equal(normalizeAggregateRows(result, { count: '*', sum: 'age', avg: 'age' }), result);
68
+ assert.equal(normalizeAggregateRows(rows), rows);
69
+ assert.equal(normalizeAggregateRows(rows, {}), rows);
70
+ });
71
+
72
+ it('preserves absent and undefined aliases and supports column lists', () => {
73
+ const rows = [{ count_all: 0n, count_imageId: undefined, name: 'unchanged' }];
74
+ assert.deepEqual(normalizeAggregateRows(rows, { count: ['*', 'imageId', 'name'] }), [
75
+ { count_all: '0', count_imageId: undefined, name: 'unchanged' },
76
+ ]);
77
+ assert.equal(Object.hasOwn(rows[0], 'count_name'), false);
78
+ const empty: object[] = [];
79
+ assert.equal(normalizeAggregateRows(empty, { count: '*' }), empty);
80
+ });
81
+ });
@@ -0,0 +1,208 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import type { IAnalysisPolicy } from '../src/types/analysis.ts';
5
+
6
+ import {
7
+ AnalysisError,
8
+ analysisCapabilities,
9
+ analysisControlSchemas,
10
+ resolveAnalysis,
11
+ } from '../src/lib/analysis/controls.ts';
12
+ import { $executeAnalysisAggregate, $executeAnalysisGroup } from '../src/lib/analysis/execute.ts';
13
+
14
+ class Model {
15
+ calls: unknown[] = [];
16
+ rows: object[] = [];
17
+ group(params: unknown) {
18
+ this.calls.push(params);
19
+ return Promise.resolve(this.rows);
20
+ }
21
+
22
+ aggregate(params: unknown) {
23
+ this.calls.push(params);
24
+ return Promise.resolve(this.rows[0]);
25
+ }
26
+ }
27
+ const policy = {
28
+ modelLike: Model,
29
+ modelClass: Model,
30
+ groups: ['level', 'category'],
31
+ aggrs: {
32
+ count: ['*', 'imageId'],
33
+ sum: ['amount'],
34
+ avg: ['amount'],
35
+ min: ['amount'],
36
+ max: ['amount'],
37
+ },
38
+ defaults: {
39
+ group: { groups: ['level'], aggrs: { count: ['*'] } },
40
+ aggregate: { aggrs: { count: ['*', 'imageId'] } },
41
+ },
42
+ limits: { maxGroups: 2, maxMetrics: 5, maxGroupRows: 2 },
43
+ } as unknown as IAnalysisPolicy;
44
+ const invalid = (run: () => unknown) =>
45
+ assert.throws(
46
+ run,
47
+ error => error instanceof AnalysisError && error.status === 422 && error.code === 422,
48
+ );
49
+
50
+ describe('analysis controls', () => {
51
+ it('defaults each omitted control independently and canonicalizes policy order', () => {
52
+ assert.deepEqual(resolveAnalysis(policy, 'group'), {
53
+ groups: ['level'],
54
+ aggrs: { count: ['*'] },
55
+ });
56
+ assert.deepEqual(resolveAnalysis(policy, 'group', { groups: ['category'] }), {
57
+ groups: ['category'],
58
+ aggrs: { count: ['*'] },
59
+ });
60
+ assert.deepEqual(resolveAnalysis(policy, 'group', { aggrs: { sum: ['amount'] } }), {
61
+ groups: ['level'],
62
+ aggrs: { sum: ['amount'] },
63
+ });
64
+ assert.deepEqual(
65
+ resolveAnalysis(policy, 'group', {
66
+ groups: ['category', 'level'],
67
+ aggrs: { count: ['imageId', '*'] },
68
+ }),
69
+ { groups: ['level', 'category'], aggrs: { count: ['*', 'imageId'] } },
70
+ );
71
+ assert.deepEqual(resolveAnalysis(policy, 'aggregate'), { aggrs: { count: ['*', 'imageId'] } });
72
+ });
73
+
74
+ it('rejects empty, duplicated, unknown, qualified and malformed controls', () => {
75
+ for (const input of [
76
+ { groups: [] },
77
+ { groups: ['level', 'level'] },
78
+ { groups: ['unknown'] },
79
+ { groups: ['table.level'] },
80
+ { groups: 'level' },
81
+ { groups: '["level"]' },
82
+ { groups: [1] },
83
+ { groups: null },
84
+ { aggrs: '{"count":["*"]}' },
85
+ { aggrs: { count: '["*"]' } },
86
+ { aggrs: { count: '*' } },
87
+ { aggrs: { count: [1] } },
88
+ { aggrs: {} },
89
+ { aggrs: null },
90
+ { aggrs: [] },
91
+ { aggrs: { count: [] } },
92
+ { aggrs: { count: ['*', '*'] } },
93
+ { aggrs: { count: ['table.imageId'] } },
94
+ { aggrs: { count: ['unknown'] } },
95
+ { aggrs: { sum: ['imageId'] } },
96
+ { aggrs: { median: ['amount'] } },
97
+ { aggrs: { 'sql.count': ['*'] } },
98
+ { aggrs: { count: ['*'], avg: [] } },
99
+ { joins: [] },
100
+ { having: {} },
101
+ { distinct: false },
102
+ { include: {} },
103
+ { with: {} },
104
+ 'not-an-object',
105
+ null,
106
+ [],
107
+ ])
108
+ invalid(() => resolveAnalysis(policy, 'group', input));
109
+ for (const groups of [[], ['level'], undefined, null])
110
+ invalid(() => resolveAnalysis(policy, 'aggregate', { groups }));
111
+ invalid(() => resolveAnalysis(policy, 'other' as never));
112
+ });
113
+
114
+ it('applies combined metric limits and refuses disabled actions', () => {
115
+ invalid(() =>
116
+ resolveAnalysis({ ...policy, limits: { ...policy.limits, maxGroups: 1 } }, 'group', {
117
+ groups: ['level', 'category'],
118
+ }),
119
+ );
120
+ invalid(() => resolveAnalysis(policy, 'aggregate', { aggrs: policy.aggrs }));
121
+ invalid(() =>
122
+ resolveAnalysis({ ...policy, defaults: { aggregate: policy.defaults.aggregate } }, 'group'),
123
+ );
124
+ assert.deepEqual(
125
+ resolveAnalysis(
126
+ { ...policy, groups: [], defaults: { aggregate: policy.defaults.aggregate } },
127
+ 'aggregate',
128
+ ),
129
+ { aggrs: { count: ['*', 'imageId'] } },
130
+ );
131
+ });
132
+
133
+ it('exposes strict native schemas and only serializable public capabilities', () => {
134
+ const schemas = analysisControlSchemas(policy, 'group');
135
+ assert.equal(
136
+ schemas.selection.safeParse({ aggrs: { count: ['*'], unknown: ['amount'] } }).success,
137
+ false,
138
+ );
139
+ assert.equal(schemas.selection.safeParse({ extra: true }).success, false);
140
+ assert.equal(
141
+ analysisControlSchemas(policy, 'aggregate').selection.safeParse({ groups: ['level'] })
142
+ .success,
143
+ false,
144
+ );
145
+ assert.deepEqual(schemas.selection.parse({}), { groups: ['level'], aggrs: { count: ['*'] } });
146
+ const capabilities = analysisCapabilities(policy, 'group');
147
+ assert.deepEqual(JSON.parse(JSON.stringify(capabilities)), {
148
+ mode: 'group',
149
+ groups: ['level', 'category'],
150
+ aggrs: policy.aggrs,
151
+ defaults: policy.defaults.group,
152
+ limits: policy.limits,
153
+ });
154
+ assert.deepEqual(analysisCapabilities(policy, 'aggregate').groups, []);
155
+ (capabilities.groups as string[]).push('external');
156
+ assert.deepEqual(policy.groups, ['level', 'category']);
157
+ });
158
+ });
159
+
160
+ describe('analysis execution boundary', () => {
161
+ it('forwards only scoped where, resolved controls, canonical ordering and cap+1', async () => {
162
+ const model = new Model();
163
+ const where = { level: 1 };
164
+ model.rows = [
165
+ { level: 1, count_all: '2' },
166
+ { level: 2, count_all: '1' },
167
+ ];
168
+ const rows = await $executeAnalysisGroup(model as never, policy, {
169
+ where,
170
+ limit: 999,
171
+ offset: 99,
172
+ columns: ['secret'],
173
+ orders: [['secret', 'desc']],
174
+ });
175
+ assert.equal(rows, model.rows);
176
+ assert.deepEqual(model.calls, [
177
+ { where, groups: ['level'], aggrs: { count: ['*'] }, orders: [['level', 'asc']], limit: 3 },
178
+ ]);
179
+ model.calls = [];
180
+ model.rows = [{ count_all: '0', sum_amount: null }];
181
+ const item = await $executeAnalysisAggregate(model as never, policy, {
182
+ where,
183
+ aggrs: { count: ['*'], sum: ['amount'] },
184
+ limit: 1,
185
+ });
186
+ assert.equal(item, model.rows[0]);
187
+ assert.deepEqual(model.calls, [{ where, aggrs: { count: ['*'], sum: ['amount'] } }]);
188
+ });
189
+
190
+ it('rejects overflow, wrong models and invalid direct calls before execution', async () => {
191
+ const model = new Model();
192
+ model.rows = [{}, {}, {}];
193
+ await assert.rejects($executeAnalysisGroup(model as never, policy), AnalysisError);
194
+ model.calls = [];
195
+ await assert.rejects(
196
+ $executeAnalysisGroup(model as never, policy, { groups: [] }),
197
+ AnalysisError,
198
+ );
199
+ await assert.rejects(
200
+ $executeAnalysisAggregate(model as never, policy, { joins: [] } as never),
201
+ AnalysisError,
202
+ );
203
+ await assert.rejects($executeAnalysisAggregate({} as never, policy), AnalysisError);
204
+ assert.deepEqual(model.calls, []);
205
+ model.rows = [];
206
+ assert.deepEqual(await $executeAnalysisGroup(model as never, policy), []);
207
+ });
208
+ });
@@ -0,0 +1,112 @@
1
+ import type { BeanModelMeta } from '../src/lib/bean.model/bean.model_meta.ts';
2
+ import type {
3
+ TypeAnalysisAggregateParams,
4
+ TypeAnalysisAggregateResult,
5
+ TypeAnalysisGroupParams,
6
+ TypeAnalysisGroupResult,
7
+ } from '../src/types/analysis.ts';
8
+
9
+ import { $defineAnalysis } from '../src/lib/analysis/policy.ts';
10
+
11
+ function expectType<_T extends true>() {}
12
+ type Equal<A, B> =
13
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
14
+
15
+ type Entity = {
16
+ id: string;
17
+ iid: number;
18
+ level: number;
19
+ amount?: number | null;
20
+ name: string;
21
+ description?: string | null;
22
+ imageId?: string;
23
+ };
24
+ declare const Model: new () => BeanModelMeta<Entity>;
25
+
26
+ // Compile-time assertions only; the factory is never executed by this file.
27
+ function checkAnalysisTypes() {
28
+ const policy = $defineAnalysis(() => Model, {
29
+ groups: ['level', 'description'],
30
+ aggrs: { count: ['*', 'imageId'], sum: ['amount'] },
31
+ defaults: {
32
+ group: { groups: ['level'], aggrs: { count: ['*'] } },
33
+ aggregate: { aggrs: { count: ['imageId'] } },
34
+ },
35
+ });
36
+ expectType<Equal<typeof policy.groups, readonly ['level', 'description']>>();
37
+ expectType<Equal<typeof policy.aggrs.count, readonly ['*', 'imageId']>>();
38
+ expectType<
39
+ Equal<
40
+ TypeAnalysisAggregateResult<typeof policy>,
41
+ { count_all?: string; count_imageId?: string; sum_amount?: string | null }
42
+ >
43
+ >();
44
+ expectType<
45
+ Equal<TypeAnalysisGroupResult<typeof policy>['description'], string | null | undefined>
46
+ >();
47
+ expectType<Equal<TypeAnalysisGroupResult<typeof policy>['level'], number | undefined>>();
48
+ const group: TypeAnalysisGroupParams<typeof policy> = {
49
+ groups: ['description'],
50
+ aggrs: { sum: ['amount'] },
51
+ where: { name: 'x' },
52
+ };
53
+ const aggregate: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { count: ['imageId'] } };
54
+ void group;
55
+ void aggregate;
56
+ // @ts-expect-error unapproved group
57
+ const badGroup: TypeAnalysisGroupParams<typeof policy> = { groups: ['name'] };
58
+ // @ts-expect-error field was allowed for count, not sum
59
+ const badColumn: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { sum: ['imageId'] } };
60
+ // @ts-expect-error aggregate has no group controls
61
+ const badAggregate: TypeAnalysisAggregateParams<typeof policy> = { groups: ['level'] };
62
+ // @ts-expect-error unknown function
63
+ const badFunction: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { median: ['amount'] } };
64
+ void [badGroup, badColumn, badAggregate, badFunction];
65
+ $defineAnalysis(Model, {
66
+ groups: ['level'],
67
+ // @ts-expect-error non-count metrics must be numeric
68
+ aggrs: { sum: ['name'] },
69
+ defaults: { aggregate: { aggrs: {} } },
70
+ });
71
+ $defineAnalysis(Model, {
72
+ groups: ['level'],
73
+ // @ts-expect-error identity columns cannot be summed
74
+ aggrs: { sum: ['iid'] },
75
+ defaults: { aggregate: { aggrs: {} } },
76
+ });
77
+ $defineAnalysis(Model, {
78
+ // @ts-expect-error qualified columns are not physical field names
79
+ groups: ['table.level'],
80
+ aggrs: { count: ['*'] },
81
+ defaults: { aggregate: { aggrs: { count: ['*'] } } },
82
+ });
83
+ $defineAnalysis(Model, {
84
+ groups: [],
85
+ // @ts-expect-error unknown function
86
+ aggrs: { count: ['*'], median: ['level'] },
87
+ defaults: { aggregate: { aggrs: { count: ['*'] } } },
88
+ });
89
+ $defineAnalysis(Model, {
90
+ groups: ['level'],
91
+ aggrs: { count: ['*'] },
92
+ defaults: {
93
+ // @ts-expect-error defaults cannot widen the group allowlist
94
+ group: { groups: ['name'], aggrs: { count: ['*'] } },
95
+ },
96
+ });
97
+ $defineAnalysis(Model, {
98
+ groups: ['level'],
99
+ aggrs: { count: ['*'] },
100
+ // @ts-expect-error defaults cannot widen the metric allowlist
101
+ defaults: { aggregate: { aggrs: { count: ['imageId'] } } },
102
+ });
103
+ const readonlyConfig = {
104
+ groups: ['level'],
105
+ aggrs: { count: ['*'] },
106
+ defaults: { group: { groups: ['level'], aggrs: { count: ['*'] } } },
107
+ } as const;
108
+ const readonlyPolicy = $defineAnalysis(Model, readonlyConfig);
109
+ expectType<Equal<typeof readonlyPolicy.aggrs.count, readonly ['*']>>();
110
+ void [policy, readonlyPolicy];
111
+ }
112
+ void checkAnalysisTypes;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.36",
3
+ "version": "5.1.37",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-queue",
3
- "version": "5.1.17",
3
+ "version": "5.1.18",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ratelimit",
3
- "version": "5.1.5",
3
+ "version": "5.1.6",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-user",
3
- "version": "5.1.27",
3
+ "version": "5.1.28",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-web",
3
- "version": "5.1.24",
3
+ "version": "5.1.25",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,4 +1,44 @@
1
1
  // eslint-disable
2
+ /** filterTransform: begin */
3
+ export * from '../bean/filterTransform.base.ts';
4
+ export * from '../bean/filterTransform.dateRange.ts';
5
+ import type { IFilterTransformOptionsBase } from '../bean/filterTransform.base.ts';
6
+ import type { IFilterTransformOptionsDateRange } from '../bean/filterTransform.dateRange.ts';
7
+ import 'vona-module-a-web';
8
+ declare module 'vona-module-a-web' {
9
+
10
+ export interface IFilterTransformRecord {
11
+ 'a-web:base': IFilterTransformOptionsBase;
12
+ 'a-web:dateRange': IFilterTransformOptionsDateRange;
13
+ }
14
+
15
+
16
+ }
17
+ declare module 'vona-module-a-web' {
18
+
19
+ export interface FilterTransformBase {
20
+ /** @internal */
21
+ get scope(): ScopeModuleAWeb;
22
+ }
23
+
24
+ export interface FilterTransformBase {
25
+ get $beanFullName(): 'a-web.filterTransform.base';
26
+ get $onionName(): 'a-web:base';
27
+ get $onionOptions(): IFilterTransformOptionsBase;
28
+ }
29
+
30
+ export interface FilterTransformDateRange {
31
+ /** @internal */
32
+ get scope(): ScopeModuleAWeb;
33
+ }
34
+
35
+ export interface FilterTransformDateRange {
36
+ get $beanFullName(): 'a-web.filterTransform.dateRange';
37
+ get $onionName(): 'a-web:dateRange';
38
+ get $onionOptions(): IFilterTransformOptionsDateRange;
39
+ }
40
+ }
41
+ /** filterTransform: end */
2
42
  /** pipe: begin */
3
43
  export * from '../bean/pipe.filter.ts';
4
44
  export * from '../bean/pipe.valid.ts';
@@ -167,46 +207,6 @@ declare module 'vona-module-a-web' {
167
207
  }
168
208
  }
169
209
  /** startup: end */
170
- /** filterTransform: begin */
171
- export * from '../bean/filterTransform.base.ts';
172
- export * from '../bean/filterTransform.dateRange.ts';
173
- import type { IFilterTransformOptionsBase } from '../bean/filterTransform.base.ts';
174
- import type { IFilterTransformOptionsDateRange } from '../bean/filterTransform.dateRange.ts';
175
- import 'vona-module-a-web';
176
- declare module 'vona-module-a-web' {
177
-
178
- export interface IFilterTransformRecord {
179
- 'a-web:base': IFilterTransformOptionsBase;
180
- 'a-web:dateRange': IFilterTransformOptionsDateRange;
181
- }
182
-
183
-
184
- }
185
- declare module 'vona-module-a-web' {
186
-
187
- export interface FilterTransformBase {
188
- /** @internal */
189
- get scope(): ScopeModuleAWeb;
190
- }
191
-
192
- export interface FilterTransformBase {
193
- get $beanFullName(): 'a-web.filterTransform.base';
194
- get $onionName(): 'a-web:base';
195
- get $onionOptions(): IFilterTransformOptionsBase;
196
- }
197
-
198
- export interface FilterTransformDateRange {
199
- /** @internal */
200
- get scope(): ScopeModuleAWeb;
201
- }
202
-
203
- export interface FilterTransformDateRange {
204
- get $beanFullName(): 'a-web.filterTransform.dateRange';
205
- get $onionName(): 'a-web:dateRange';
206
- get $onionOptions(): IFilterTransformOptionsDateRange;
207
- }
208
- }
209
- /** filterTransform: end */
210
210
  /** config: begin */
211
211
  export * from '../config/config.ts';
212
212
  import type { config } from '../config/config.ts';
@@ -8,13 +8,15 @@ import type {
8
8
  ITableQuery,
9
9
  RouteHandlerArgumentMeta,
10
10
  } from 'vona-module-a-openapi';
11
+ import type { IAnalysisPolicy, TypeAnalysisMode } from 'vona-module-a-orm';
11
12
  import type { ValidatorOptions } from 'vona-module-a-validation';
12
13
 
13
14
  import { isNil, isNilOrEmptyString } from '@cabloy/utils';
14
15
  import { ZodMetadata } from '@cabloy/zod-openapi';
15
16
  import { isNullableSchema } from '@cabloy/zod-query';
16
- import { BeanBase, beanFullNameFromOnionName, cast } from 'vona';
17
+ import { BeanBase, beanFullNameFromOnionName, cast, runWithZodQueryCoercion } from 'vona';
17
18
  import { createArgumentPipe, Pipe } from 'vona-module-a-aspect';
19
+ import { analysisFailure, resolveAnalysis } from 'vona-module-a-orm';
18
20
 
19
21
  import type {
20
22
  IFilterTransformRecord,
@@ -28,9 +30,13 @@ export type TypePipeFilterResult = TypeQueryParamsPatch;
28
30
  type TypeQueryJoin = NonNullable<TypeQueryParamsPatch['joins']>[number];
29
31
 
30
32
  export interface IPipeOptionsFilter
31
- extends IDecoratorPipeOptions, IDecoratorPipeOptionsArgument, ValidatorOptions {}
33
+ extends IDecoratorPipeOptions, IDecoratorPipeOptionsArgument, ValidatorOptions {
34
+ analysis?: IAnalysisPolicy;
35
+ analysisMode?: TypeAnalysisMode;
36
+ }
32
37
 
33
38
  const __FieldsSystem = ['columns', 'where', 'orders', 'pageNo', 'pageSize'];
39
+ const __AnalysisControls = ['groups', 'aggrs', 'having', 'joins', 'distinct', 'include', 'with'];
34
40
 
35
41
  @Pipe<IPipeOptionsFilter>({
36
42
  // ValidatorOptions
@@ -53,6 +59,7 @@ export class PipeFilter
53
59
  `should specify the schema of pipeFilter: ${metadata.controller.name}.${metadata.method}#${metadata.index}`,
54
60
  );
55
61
  }
62
+ value = this._prepareAnalysisControls(value, options);
56
63
  // validateSchema
57
64
  value = (await this.bean.validator.validateSchema(
58
65
  options.schema,
@@ -66,6 +73,35 @@ export class PipeFilter
66
73
  return params;
67
74
  }
68
75
 
76
+ private _prepareAnalysisControls(value: TypePipeFilterData, options: IPipeOptionsFilter) {
77
+ if (!!options.analysis !== !!options.analysisMode) {
78
+ throw new Error('Analysis filter requires both a policy and an action mode');
79
+ }
80
+ if (!value || typeof value !== 'object') return value;
81
+ const prepared = { ...value };
82
+ for (const key of __AnalysisControls) {
83
+ if (!Object.hasOwn(value, key)) continue;
84
+ if (!options.analysisMode) analysisFailure(`Select does not support ${key}`);
85
+ if (key !== 'aggrs' && !(key === 'groups' && options.analysisMode === 'group')) {
86
+ analysisFailure(`Analysis ${options.analysisMode} does not support ${key}`);
87
+ }
88
+ if (typeof value[key] === 'string') {
89
+ try {
90
+ prepared[key] = JSON.parse(value[key]);
91
+ } catch {
92
+ analysisFailure(`Analysis ${key} must be valid JSON`);
93
+ }
94
+ }
95
+ }
96
+ if (options.analysis && options.analysisMode) {
97
+ // Validate native shapes before permissive generic query coercion runs.
98
+ runWithZodQueryCoercion(false, () =>
99
+ resolveAnalysis(options.analysis!, options.analysisMode!, prepared),
100
+ );
101
+ }
102
+ return prepared;
103
+ }
104
+
69
105
  private async _transform(value: TypePipeFilterData, options: IPipeOptionsFilter) {
70
106
  // 1. system: columns/where/orders/pageNo/pageSize
71
107
  const params = this._transformSystem(value);
@@ -76,6 +112,10 @@ export class PipeFilter
76
112
  await this._transformFields(params, value, options, rootOpenapi);
77
113
  // 3. system: orders
78
114
  this._transformOrders(params, options, rootOpenapi);
115
+ if (options.analysis && options.analysisMode) {
116
+ if (params.joins?.length) analysisFailure('Analysis does not support joined filters');
117
+ Object.assign(params, resolveAnalysis(options.analysis, options.analysisMode, value));
118
+ }
79
119
  // ok
80
120
  return params;
81
121
  }
@@ -188,7 +228,8 @@ export class PipeFilter
188
228
  options: IPipeOptionsFilter,
189
229
  rootOpenapi: ISchemaObjectExtensionField | undefined,
190
230
  ) {
191
- if (__FieldsSystem.includes(key)) return;
231
+ if (__FieldsSystem.includes(key) || (options.analysisMode && __AnalysisControls.includes(key)))
232
+ return;
192
233
  const info = this._resolveField(key, options, rootOpenapi);
193
234
  if (!info) return;
194
235
  const { fieldSchema, fieldSchemaInner, openapi, originalName, fullName, joinInfo } = info;