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.
- package/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +50 -3
- package/.vscode/settings.json +1 -2
- package/CHANGELOG.md +31 -0
- package/package.json +1 -7
- package/repo-agent-governance/managed-assets.json +12 -12
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +2 -2
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +1 -1
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +73 -22
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +98 -7
- package/repo-docs/ai/playbook-spec-execution.md +1 -1
- package/repo-docs/backend/crud-workflow.md +92 -1
- package/repo-docs/backend/dto-guide.md +16 -1
- package/repo-docs/backend/dto-infer-generation.md +23 -5
- package/repo-docs/backend/orm-aggregate-group-guide.md +32 -0
- package/repo-docs/backend/validation-guide.md +38 -2
- package/repo-e2e/specs/cabloy-basic.spec.ts +9 -2
- package/vona/package.original.json +1 -7
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/aggregate/src/dto/{{resourceName}}AggregateRes.tsx_ +11 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/src/lib/{{resourceName}}Analysis.ts_ +14 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/test/{{resourceName}}Analysis.test.ts_ +81 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/group/src/dto/{{resourceName}}GroupResItem.tsx_ +11 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +4 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/test/{{resourceName}}.test.ts_ +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/3-en-us.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/4-zh-cn.ts +2 -0
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudBasic.ts +2 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudStart.ts +2 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -1
- package/vona/packages-cli/cli-set-api/src/lib/crudAnalysis.ts +11 -0
- package/vona/packages-cli/cli-set-api/test/crudAnalysis.test.ts +385 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +76 -1008
- package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +18 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +28 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentAggregateRes.tsx +11 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentGroupResItem.tsx +11 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectReq.tsx +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/lib/studentAnalysis.ts +12 -0
- package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +22 -1
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +4 -0
- package/vona/src/suite/a-training/modules/training-student/test/studentAnalysis.test.ts +422 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-locale/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/utils.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/.metadata/index.ts +26 -26
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/aggregateResult.ts +53 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/controls.ts +163 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/execute.ts +49 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/index.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/policy.ts +140 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_cache.ts +4 -2
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +8 -3
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dto.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAggregate.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAnalysis.ts +34 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/analysis.ts +137 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/relationsAggr.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/aggregateResult.test.ts +81 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.test.ts +208 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.types.test.ts +112 -0
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/.metadata/index.ts +40 -40
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/bean/pipe.filter.ts +44 -3
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/analysisFilter.ts +69 -0
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/decorator/arguments.ts +6 -4
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/dto.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/filter.ts +7 -2
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +0 -6
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +2 -2
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +1 -1
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +1 -1
- package/zova/pnpm-lock.yaml +11 -963
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +4 -1
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx +2 -2
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +16 -2
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +201 -7
- package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +42 -0
- package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +10 -0
- package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +5 -5
- package/vona/.textlintrc.json +0 -19
|
@@ -155,7 +155,7 @@ test('creates a mixed-status model with deferred scope separated', () => {
|
|
|
155
155
|
assert.doesNotMatch(renderGantt(model, 'Demo'), /--teal:/);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
test('planning completion requires an explicit task mode and
|
|
158
|
+
test('planning completion requires an explicit task mode and reduces only unfinished scope', () => {
|
|
159
159
|
const wbs = files['pdp-wbs.md'].replace(
|
|
160
160
|
'#### WBS-10-01: Freeze the baseline',
|
|
161
161
|
'#### WBS-10-01: Freeze the baseline\n\nCompletion mode: planning-only.',
|
|
@@ -179,13 +179,16 @@ test('planning completion requires an explicit task mode and preserves verified-
|
|
|
179
179
|
});
|
|
180
180
|
assert.equal(model.tasks[0].completionMode, 'planning-only');
|
|
181
181
|
assert.equal(model.verified, 0);
|
|
182
|
+
assert.equal(model.planningComplete, 1);
|
|
183
|
+
assert.equal(model.completed, 1);
|
|
184
|
+
assert.equal(model.remaining, 1);
|
|
182
185
|
const gantt = renderGantt(model, 'Demo');
|
|
183
186
|
assert.match(gantt, /Planning complete/);
|
|
184
187
|
assert.match(gantt, /--teal:#087f83/);
|
|
185
|
-
assert.
|
|
188
|
+
assert.match(renderBurndown(model, 'Demo'), /--teal:#087f83/);
|
|
186
189
|
assert.match(gantt, /class="caption" x="96" y="195">WBS-20-01: Deliver/);
|
|
187
190
|
assert.doesNotMatch(gantt, /class="caption" x="96" y="195">WBS-10-01/);
|
|
188
|
-
assert.match(renderBurndown(model, 'Demo'), />
|
|
191
|
+
assert.match(renderBurndown(model, 'Demo'), />1 remaining · 0 verified · 1 planning complete</);
|
|
189
192
|
const blocked = createChartModel({
|
|
190
193
|
readme: files['README.md'],
|
|
191
194
|
wbs,
|
|
@@ -201,7 +204,91 @@ test('planning completion requires an explicit task mode and preserves verified-
|
|
|
201
204
|
testPlan: files['test-plan.md'],
|
|
202
205
|
});
|
|
203
206
|
assert.match(renderGantt(chinese, '示例'), /规划完成/);
|
|
204
|
-
assert.match(renderBurndown(chinese, '示例'), /
|
|
207
|
+
assert.match(renderBurndown(chinese, '示例'), /1 剩余 · 0 已核验 · 1 规划完成/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('verified and planning closure finish scope without conflating acceptance', () => {
|
|
211
|
+
for (const [readme, suite] of [
|
|
212
|
+
[files['README.md'], 'Demo'],
|
|
213
|
+
['# 中文规划\n完整中文记录。', '示例'],
|
|
214
|
+
]) {
|
|
215
|
+
const model = createChartModel({
|
|
216
|
+
readme,
|
|
217
|
+
wbs: files['pdp-wbs.md'].replace(
|
|
218
|
+
'#### WBS-10-01: Freeze the baseline',
|
|
219
|
+
'#### WBS-10-01: Freeze the baseline\nCompletion mode: planning-only.',
|
|
220
|
+
),
|
|
221
|
+
progress: files['progress.md']
|
|
222
|
+
.replace('`not-started`', '`planning-complete`')
|
|
223
|
+
.replace('`in-progress`', '`verified`'),
|
|
224
|
+
testPlan: files['test-plan.md'],
|
|
225
|
+
});
|
|
226
|
+
assert.equal(model.activeTasks.length, 2);
|
|
227
|
+
assert.equal(model.deferredTasks.length, 1);
|
|
228
|
+
assert.equal(model.verified, 1);
|
|
229
|
+
assert.equal(model.planningComplete, 1);
|
|
230
|
+
assert.equal(model.completed, 2);
|
|
231
|
+
assert.equal(model.remaining, 0);
|
|
232
|
+
const svg = renderBurndown(model, suite);
|
|
233
|
+
assert.match(svg, /<circle cx="910\.0" cy="650\.0" r="14"/);
|
|
234
|
+
if (model.language === 'en') {
|
|
235
|
+
assert.match(svg, />0 remaining · 1 verified · 1 planning complete</);
|
|
236
|
+
assert.match(svg, /remaining = active scope − verified − planning complete/);
|
|
237
|
+
assert.match(
|
|
238
|
+
svg,
|
|
239
|
+
/aria-label="Phase 10: 1 active; 0 verified; 1 planning complete; 0 remaining"/,
|
|
240
|
+
);
|
|
241
|
+
assert.match(
|
|
242
|
+
svg,
|
|
243
|
+
/aria-label="Phase 20: 1 active; 1 verified; 0 planning complete; 0 remaining"/,
|
|
244
|
+
);
|
|
245
|
+
assert.match(svg, /<text class="small" x="1228" y="832">Planning complete</);
|
|
246
|
+
} else {
|
|
247
|
+
assert.match(svg, />0 剩余 · 1 已核验 · 1 规划完成</);
|
|
248
|
+
assert.match(svg, /剩余 = 活跃范围 − 已核验 − 规划完成/);
|
|
249
|
+
assert.match(svg, /aria-label="阶段 10:1 项活跃;0 项已核验;1 项规划完成;0 项剩余"/);
|
|
250
|
+
assert.match(svg, /<text class="small" x="1228" y="832">规划完成</);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('unfinished statuses remain unfinished and mixed phase counts agree with scope', () => {
|
|
256
|
+
const statuses = [
|
|
257
|
+
'verified',
|
|
258
|
+
'planning-complete',
|
|
259
|
+
'not-started',
|
|
260
|
+
'in-progress',
|
|
261
|
+
'implementation-complete',
|
|
262
|
+
'blocked',
|
|
263
|
+
'waived',
|
|
264
|
+
'deferred',
|
|
265
|
+
];
|
|
266
|
+
const model = createChartModel({
|
|
267
|
+
readme: files['README.md'],
|
|
268
|
+
wbs: `### Phase 10: Mixed closure\nDependencies: none.\n${statuses
|
|
269
|
+
.map(
|
|
270
|
+
(status, index) =>
|
|
271
|
+
`#### WBS-MIX-${index}: Work ${index}\n${status === 'planning-complete' ? 'Completion mode: planning-only.\n' : ''}`,
|
|
272
|
+
)
|
|
273
|
+
.join('\n')}`,
|
|
274
|
+
progress: `| WBS ID | Status |\n| --- | --- |\n${statuses.map((status, index) => `| WBS-MIX-${index} | ${status} |`).join('\n')}`,
|
|
275
|
+
testPlan: '',
|
|
276
|
+
});
|
|
277
|
+
assert.equal(model.activeTasks.length, 7);
|
|
278
|
+
assert.equal(model.deferredTasks.length, 1);
|
|
279
|
+
assert.equal(model.verified, 1);
|
|
280
|
+
assert.equal(model.planningComplete, 1);
|
|
281
|
+
assert.equal(model.completed, 2);
|
|
282
|
+
assert.equal(model.remaining, 5);
|
|
283
|
+
const svg = renderBurndown(model, 'Demo');
|
|
284
|
+
assert.match(
|
|
285
|
+
svg,
|
|
286
|
+
/aria-label="Phase 10: 7 active; 1 verified; 1 planning complete; 5 remaining"/,
|
|
287
|
+
);
|
|
288
|
+
assert.match(svg, />5 remaining · 1 verified · 1 planning complete</);
|
|
289
|
+
assert.match(svg, /<circle cx="395\.7" cy="478\.6" r="8"/);
|
|
290
|
+
assert.match(svg, /width="60" height="12" rx="6" fill="var\(--aqua\)"/);
|
|
291
|
+
assert.doesNotMatch(svg, /r="14" fill="var\(--aqua\)"/);
|
|
205
292
|
});
|
|
206
293
|
|
|
207
294
|
test('completion mode must be unique, supported and task-local', () => {
|
|
@@ -325,7 +412,7 @@ test('renders partial burndown progress at its remaining-count position', async
|
|
|
325
412
|
t.after(() => removeFixture(directory));
|
|
326
413
|
const result = await generateCharts(directory);
|
|
327
414
|
const burndown = result.artifacts.get('implementation-burndown.svg');
|
|
328
|
-
assert.match(burndown, />1 remaining · 1 verified</);
|
|
415
|
+
assert.match(burndown, />1 remaining · 1 verified · 0 planning complete</);
|
|
329
416
|
assert.match(burndown, />2 remaining</);
|
|
330
417
|
assert.doesNotMatch(burndown, />0 \/ 2</);
|
|
331
418
|
assert.match(
|
|
@@ -348,7 +435,7 @@ test('renders a completion marker only when no WBS items remain', async t => {
|
|
|
348
435
|
});
|
|
349
436
|
t.after(() => removeFixture(directory));
|
|
350
437
|
const burndown = (await generateCharts(directory)).artifacts.get('implementation-burndown.svg');
|
|
351
|
-
assert.match(burndown, />0 remaining · 2 verified</);
|
|
438
|
+
assert.match(burndown, />0 remaining · 2 verified · 0 planning complete</);
|
|
352
439
|
assert.match(burndown, /<line x1="190" y1="410" x2="910\.0" y2="650\.0"/);
|
|
353
440
|
assert.match(burndown, /<circle cx="910\.0" cy="650\.0" r="14"/);
|
|
354
441
|
assert.match(burndown, /M904\.0 650\.0 L908\.0 654\.0 L916\.0 645\.0/);
|
|
@@ -755,7 +842,7 @@ test('Chinese descriptions, tooltips, aria labels, and fallback copy contain no
|
|
|
755
842
|
assert.match(gantt, /aria-label="WBS-ZH-01: 示例工作; 依赖: 无; 受阻"/);
|
|
756
843
|
assert.match(gantt, /<title>WBS-ZH-01: 示例工作; 依赖: 无; 受阻<\/title>/);
|
|
757
844
|
assert.match(gantt, /没有已记录的依赖就绪 WBS 候选项/);
|
|
758
|
-
assert.match(burndown, /aria-label="阶段 10:0
|
|
845
|
+
assert.match(burndown, /aria-label="阶段 10:1 项活跃;0 项已核验;0 项规划完成;1 项剩余"/);
|
|
759
846
|
assert.match(gantt, /依赖就绪不代表实施批准/);
|
|
760
847
|
assert.match(burndown, /已记录 WBS 范围/);
|
|
761
848
|
});
|
|
@@ -833,6 +920,10 @@ test('writes deterministic XML-escaped artifacts and detects stale output', asyn
|
|
|
833
920
|
/implementation-gantt\.svg/,
|
|
834
921
|
);
|
|
835
922
|
const second = await generateCharts(directory);
|
|
923
|
+
assert.equal(
|
|
924
|
+
second.artifacts.get('implementation-burndown.svg'),
|
|
925
|
+
first.artifacts.get('implementation-burndown.svg'),
|
|
926
|
+
);
|
|
836
927
|
assert.equal(
|
|
837
928
|
second.artifacts.get('implementation-gantt.svg'),
|
|
838
929
|
first.artifacts.get('implementation-gantt.svg'),
|
|
@@ -132,7 +132,7 @@ Use status precisely:
|
|
|
132
132
|
- `implementation-complete` — source work is complete, but required ATP or release proof remains
|
|
133
133
|
- `verified` — all applicable WBS checks and ATPs have durable, linked, redacted observed evidence
|
|
134
134
|
|
|
135
|
-
`planning-complete` satisfies a predecessor dependency edge but does not unblock a task still recorded `blocked` or grant an execution dossier. Charts count only `verified` toward verified
|
|
135
|
+
`planning-complete` satisfies a predecessor dependency edge but does not unblock a task still recorded `blocked` or grant an execution dossier. Charts count only `verified` toward verified totals and show eligible `planning-complete` closure separately. The burndown calculates remaining = active scope − verified − eligible planning-complete; deferred scope is separate, and all other active statuses remain unfinished. Documentary closure is not runtime ATP verification. A successful build, generation command, hook, manual walkthrough, screenshot, or unrelated test run is not by itself `verified` unless the authoritative test plan defines it as sufficient retained proof. Evidence is revision- and authority-scoped; mark old proof superseded or requiring rerun when relevant source or authority changes.
|
|
136
136
|
|
|
137
137
|
## Refresh derived charts last
|
|
138
138
|
|
|
@@ -28,6 +28,97 @@ npm run vona :tools:crudBasic student -- --module=training-student
|
|
|
28
28
|
|
|
29
29
|
This is important because the repo already encodes both the full CRUD thread and a lighter CRUD-basic workflow in the CLI surface.
|
|
30
30
|
|
|
31
|
+
## Grouping and statistics
|
|
32
|
+
|
|
33
|
+
The Basic and Start CRUD generators provide `group` and `aggregate` read actions by default, alongside ordinary `select`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run vona :tools:crud report -- --module=demo-report
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The direct `:tools:crudBasic` and `:tools:crudStart` commands use the same default. There are no `--aggregate` or `--groupBy` options; edit the generated policy to configure analysis capabilities.
|
|
40
|
+
|
|
41
|
+
| Generated action | Default result |
|
|
42
|
+
| ---------------- | ---------------------------------------- |
|
|
43
|
+
| `GET select` | Resource records and pagination metadata |
|
|
44
|
+
| `GET group` | One row per name, with `count_all` |
|
|
45
|
+
| `GET aggregate` | One object with `count_all` |
|
|
46
|
+
|
|
47
|
+
The generator exports one shared analysis policy in `src/lib/<resourceName>Analysis.ts`:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
export const reportAnalysis = $defineAnalysis(() => ModelReport, {
|
|
51
|
+
groups: ['name'],
|
|
52
|
+
aggrs: { count: ['*'] },
|
|
53
|
+
defaults: {
|
|
54
|
+
group: { groups: ['name'], aggrs: { count: ['*'] } },
|
|
55
|
+
aggregate: { aggrs: { count: ['*'] } },
|
|
56
|
+
},
|
|
57
|
+
limits: { maxGroups: 3, maxMetrics: 10, maxGroupRows: 1000 },
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Default API generation does not expose every Entity field. The initial allowlist contains only `name` and `count('*')`; it does not automatically allow `description`, identity fields, timestamps, or numeric metrics. Callers can select combinations only within that backend-owned policy, not arbitrary model columns or SQL expressions. No analysis capability fields are added to the Entity.
|
|
62
|
+
|
|
63
|
+
Fresh CRUD entities contain `name`, so name grouping provides a runnable starting point, not a universal business default. Refine the policy to use meaningful dimensions such as status, category, or level as the resource evolves. Choose dimensions with suitable cardinality: grouping by a nearly unique name can return almost as many groups as records. Generated group actions do not provide group pagination or a group-count total.
|
|
64
|
+
|
|
65
|
+
These commands create new resources and refuse an existing controller. Do not rerun them against the existing `training-student` resource to add actions. For an existing resource, define its policy and reuse it in the request DTO, response DTOs, and service; the Student reference uses its already-defined `level` field.
|
|
66
|
+
|
|
67
|
+
### Query and response boundaries
|
|
68
|
+
|
|
69
|
+
`select`, `group`, and `aggregate` remain separate contracts:
|
|
70
|
+
|
|
71
|
+
- `select` returns resource records and existing pagination metadata.
|
|
72
|
+
- `group` returns group keys and metrics, not nested lists of resource records.
|
|
73
|
+
- `aggregate` returns metrics over all matching source records, not the current select page.
|
|
74
|
+
|
|
75
|
+
The actions reuse the resource's `SelectReq`; no separate `AnalysisReq` is generated. `@Dto({ analysis: resourceAnalysis })` attaches the shared policy, and `@Arg.filter(SelectReq, { mode: 'group' })` or `{ mode: 'aggregate' }` derives that action's controls without adding them to ordinary select. Select rejects explicit analysis controls. The service uses `$executeAnalysisGroup(model, policy, params)` or `$executeAnalysisAggregate(model, policy, params)`, which also revalidate controls for direct service callers.
|
|
76
|
+
|
|
77
|
+
A group request may select `groups: string[]` and `aggrs: { count?: string[]; sum?: string[]; avg?: string[]; min?: string[]; max?: string[] }`; an aggregate request accepts only `aggrs`. Every function and column must be allowed by the policy. Omitted controls independently use the action's defaults. Explicit empty selections, duplicates, unsupported functions/columns, and `groups` on aggregate fail validation rather than falling back or being stripped.
|
|
78
|
+
|
|
79
|
+
Named local business filters and raw `where` retain their existing semantics. A raw predicate for a field takes precedence over that field's named-filter transformation. List projection, ordering, and pagination controls are still parsed and validated, but do not change analysis: statistics cover all matching source rows, not the current select page. Filters that require joins are rejected for analysis; ordinary select can still use its existing joined filters. Client `having`, joins, expressions, and custom aliases are not analysis controls. Keep server-side instance, soft-delete, and business authorization scope; caller-supplied predicates are not an authorization boundary.
|
|
80
|
+
|
|
81
|
+
The default policy limits are 3 selected dimensions, 10 selected metric aliases, and 1000 group-result rows; a policy may set explicit limits. Group execution reads at most the row cap plus one and returns a deliberate `422` validation/resource-limit error on overflow, not a silently truncated result. These bounds do not limit the database work needed to scan or aggregate the matching population.
|
|
82
|
+
|
|
83
|
+
Response DTOs use `$Dto.analysisGroup(policy)` / `$Dto.analysisAggregate(policy)` to describe a finite **optional superset** of allowed keys. Each response includes only requested dimensions and aliases. Optional means unrequested; nullable means a real SQL null. If a business-specific policy adds a nullable dimension, preserve both optionality and nullability in its response contract and never synthesize an unrequested key.
|
|
84
|
+
|
|
85
|
+
Count aliases are decimal strings, normalized by the ORM query/cache boundary; non-count metrics are strings or SQL `null`. Services need no count-conversion helper. An empty group query returns `[]`; an empty aggregate count returns `"0"`. Missing dimension values are not synthesized into zero-valued groups.
|
|
86
|
+
|
|
87
|
+
### Student policy and GET controls
|
|
88
|
+
|
|
89
|
+
The Student reference exports this policy in `src/lib/studentAnalysis.ts`:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
export const studentAnalysis = $defineAnalysis(() => ModelStudent, {
|
|
93
|
+
groups: ['level'],
|
|
94
|
+
aggrs: { count: ['*', 'imageId'] },
|
|
95
|
+
defaults: {
|
|
96
|
+
group: { groups: ['level'], aggrs: { count: ['*'] } },
|
|
97
|
+
aggregate: { aggrs: { count: ['*', 'imageId'] } },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Its default `GET /api/training/student/group` counts by `level`, ordered by level. Its default `GET /api/training/student/aggregate` returns `count_all` and `count_imageId` (students with a non-null image reference). A caller can request only `count_imageId`, without receiving `count_all` as an extra field. `name`, mobile, and raw timestamps are not allowed dimensions merely because the Entity contains them.
|
|
103
|
+
|
|
104
|
+
For GET requests, encode array/object controls as JSON query values using the existing `combineQueries` convention. Generated API methods accept structured controls and handle that encoding. The equivalent raw request is:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
curl --get 'http://localhost:7102/api/training/student/group' \
|
|
108
|
+
--data-urlencode 'groups=["level"]' \
|
|
109
|
+
--data-urlencode 'aggrs={"count":["imageId"]}'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use the normal authenticated session for this protected action. Do not send comma-separated dimensions or JavaScript object text. Malformed JSON is invalid input. The existing `summary/:id` remains an individual Student summary, not population statistics.
|
|
113
|
+
|
|
114
|
+
Action request schemas expose finite enums and `filter.capabilities.analysis` with the public allowlist, action defaults, mode, and effective limits, including on emitted `groups` / `aggrs` parameter schemas. Model/class references are not public capabilities. See [DTO Infer and Generation](/backend/dto-infer-generation#aggregate-and-group-dto-inference), [Validation Guide](/backend/validation-guide#analysis-action-modes), and [ORM Aggregate and Group Guide](/backend/orm-aggregate-group-guide).
|
|
115
|
+
|
|
116
|
+
### Authorization and source scope
|
|
117
|
+
|
|
118
|
+
The generated analysis actions inherit the `select` RBAC policy. Policy inheritance does not apply row filtering or copy data-scope options. Normal model instance and soft-delete predicates remain active.
|
|
119
|
+
|
|
120
|
+
For a resource that supports department/owner data scope, explicitly configure that scope and combine `rbacScopeCurrent.where(params.where)` **before** grouping or aggregation. Filtering group results afterward is not an authorization boundary. Do not enable ownership scope for Student or another resource that lacks the corresponding ownership fields. See [RBAC Authorization](/backend/rbac-authorization).
|
|
121
|
+
|
|
31
122
|
## Generated structure
|
|
32
123
|
|
|
33
124
|
The generator creates a connected set of files, typically including:
|
|
@@ -45,7 +136,7 @@ This is exactly why this generator should be the default starting point. It give
|
|
|
45
136
|
|
|
46
137
|
### Generated authorization default
|
|
47
138
|
|
|
48
|
-
The standard generated CRUD actions (`create`, `select`, `view`, `update`, and `delete`) each use `@Passport.
|
|
139
|
+
The standard generated CRUD actions (`create`, `select`, `view`, `update`, and `delete`) each use `@Passport.rbac()`. Bulk delete inherits the `delete` policy. These guards authorize API execution independently of the generated SSR menu's visibility policy.
|
|
49
140
|
|
|
50
141
|
The guard is attached to individual actions rather than the controller class. When you add a custom action later, no local Passport guard intentionally means the global authenticated-and-activated baseline applies; it does not mean the action is public. Use `@Passport.public()` to intentionally allow anonymous access, or add a role/domain guard when an action needs stronger restriction.
|
|
51
142
|
|
|
@@ -124,11 +124,12 @@ Choose the scene from the API contract, not from the particular screen that happ
|
|
|
124
124
|
|
|
125
125
|
## DTO options
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
Important DTO option areas include:
|
|
128
128
|
|
|
129
129
|
- `independent`
|
|
130
130
|
- `openapi`
|
|
131
131
|
- `fields`
|
|
132
|
+
- `analysis` for backend-controlled, client-selectable group/aggregate contracts
|
|
132
133
|
|
|
133
134
|
These options make DTOs configurable as reusable schema objects, not just local TypeScript classes.
|
|
134
135
|
|
|
@@ -138,6 +139,20 @@ A useful ownership rule is:
|
|
|
138
139
|
- app config can still override broader DTO behavior
|
|
139
140
|
- inference tools can reduce how much hand-authored DTO code is needed
|
|
140
141
|
|
|
142
|
+
### DTO-owned analysis policy
|
|
143
|
+
|
|
144
|
+
For client-selectable grouping or metrics, attach a shared backend policy with `@Dto({ analysis: studentAnalysis })`. Define it with `$defineAnalysis(() => ModelStudent, config)`, not Entity capability flags. `groups` allows physical dimensions; `aggrs` allows columns separately for each function (`count`, `sum`, `avg`, `min`, `max`). Count may allow `'*'`; non-count metrics require numeric columns. The model identity remains explicit even when the filter DTO's projection omits a legitimate metric column, such as Student `imageId`.
|
|
145
|
+
|
|
146
|
+
Reuse that same exported policy in three places:
|
|
147
|
+
|
|
148
|
+
- the filter DTO's `analysis` option and action-aware `@Arg.filter` modes
|
|
149
|
+
- named response wrappers based on `$Dto.analysisGroup(policy)` / `$Dto.analysisAggregate(policy)`
|
|
150
|
+
- service execution through `$executeAnalysisGroup(model, policy, params)` / `$executeAnalysisAggregate(model, policy, params)`
|
|
151
|
+
|
|
152
|
+
Do not create a separate AnalysisReq or add analysis controls to ordinary select. The policy owns function/column allowlists, action defaults, and execution limits; response inference describes the finite optional field superset, while execution returns selected fields only. Optional is not nullable: an unrequested metric is absent, while a selected non-count metric can be SQL `null`.
|
|
153
|
+
|
|
154
|
+
The derived action schemas publish frontend-readable `filter.capabilities.analysis` without model/class references. After changing the policy, regenerate affected OpenAPI/SDK consumers rather than duplicating the allowlist in frontend code. See [CRUD Workflow](/backend/crud-workflow#grouping-and-statistics), [DTO Infer and Generation](/backend/dto-infer-generation#aggregate-and-group-dto-inference), and [Validation Guide](/backend/validation-guide#analysis-action-modes) for the connected contract.
|
|
155
|
+
|
|
141
156
|
## App-config override support
|
|
142
157
|
|
|
143
158
|
DTO options can also be configured through app config.
|
|
@@ -444,14 +444,32 @@ The test suite also shows the behavioral difference from a one-off `columns` sel
|
|
|
444
444
|
|
|
445
445
|
## Aggregate and group DTO inference
|
|
446
446
|
|
|
447
|
-
Summary-oriented ORM queries often benefit from inferred DTOs because the result shape is driven by `aggrs`, `groups`, and relation configuration.
|
|
447
|
+
Summary-oriented ORM queries often benefit from inferred DTOs because the result shape is driven by `aggrs`, `groups`, and relation configuration. For fixed server-authored query shapes, use `$Dto.aggregate(...)` / `$Dto.group(...)` and wrap a reused contract in a named DTO.
|
|
448
448
|
|
|
449
|
-
|
|
449
|
+
For a backend allowlist with client-selectable combinations, infer from one shared `$defineAnalysis` policy instead:
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
@Dto({ analysis: studentAnalysis })
|
|
453
|
+
export class DtoStudentSelectReq extends $Dto.queryPage(EntityStudent, [
|
|
454
|
+
'name',
|
|
455
|
+
'level',
|
|
456
|
+
'createdAt',
|
|
457
|
+
]) {}
|
|
458
|
+
|
|
459
|
+
@Dto()
|
|
460
|
+
export class DtoStudentGroupResItem extends $Dto.analysisGroup(studentAnalysis) {}
|
|
461
|
+
|
|
462
|
+
@Dto()
|
|
463
|
+
export class DtoStudentAggregateRes extends $Dto.analysisAggregate(studentAnalysis) {}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
These are abbreviated declarations; keep existing request field refinements such as Student's date-range transform. The policy explicitly identifies the Model, allowed dimensions, function-specific metric columns, per-action defaults, and limits. It can therefore allow `count('imageId')` even though `imageId` is not a named filter in SelectReq. No analysis capability flags belong on the Entity, and no separate AnalysisReq is needed.
|
|
467
|
+
|
|
468
|
+
The response schema is a finite optional superset of all policy-permitted dimension and metric keys, not an unrestricted property map. Execution returns **requested keys only**, with fixed ORM aliases such as `count_all` and `count_imageId`. Group keys retain their column types; count aliases are decimal strings; non-count aliases are strings or SQL `null`. An optional property is unrequested, not a substitute for nullable. If refining a nullable description dimension, preserve both optionality and nullability (`description?: string | null`) and do not synthesize it when absent from the result.
|
|
450
469
|
|
|
451
|
-
|
|
452
|
-
- if the same summary contract is reused broadly, wrap the inferred DTO into a named DTO class
|
|
470
|
+
Select, group, and aggregate remain separate action schemas. Attach the policy to SelectReq, then use `@Arg.filter(DtoStudentSelectReq, { mode: 'group' })` or `{ mode: 'aggregate' }` on the analysis action; ordinary select remains unchanged and rejects explicit analysis controls. The named wrappers keep response references distinct, while public action capabilities and enums come from the same policy.
|
|
453
471
|
|
|
454
|
-
|
|
472
|
+
See [CRUD Workflow](/backend/crud-workflow#grouping-and-statistics) for the Student policy, GET JSON controls, and generator workflow; [Validation Guide](/backend/validation-guide#analysis-action-modes) for defaults, validation, and limits; and [ORM Aggregate and Group Guide](/backend/orm-aggregate-group-guide) for the underlying model operations.
|
|
455
473
|
|
|
456
474
|
## How DTOs surface in generated metadata
|
|
457
475
|
|
|
@@ -92,6 +92,38 @@ A practical result-shape distinction is:
|
|
|
92
92
|
- `aggregate` returns one summary object
|
|
93
93
|
- `group` returns grouped rows keyed by the chosen group columns and aggregate aliases
|
|
94
94
|
|
|
95
|
+
## Aggregate result values
|
|
96
|
+
|
|
97
|
+
The ORM adapts database-driver aggregate scalars before returning model results or storing query-cache entries. Cached results and relation aggregates follow the same contract; business services do not need their own normalization helper.
|
|
98
|
+
|
|
99
|
+
| Result | Runtime value and inferred type |
|
|
100
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
101
|
+
| `count_*` | Decimal string, with existing `undefined` optionality |
|
|
102
|
+
| `sum_*`, `avg_*`, `min_*`, `max_*` | String when populated; SQL `null` for empty/all-null inputs; existing `undefined` optionality retained |
|
|
103
|
+
| Group dimensions | Their ordinary column types and dialect conversion, not aggregate strings |
|
|
104
|
+
|
|
105
|
+
Aliases follow `${function}_${column}`, with `*` mapped to `all`, for example `count_all` and `avg_stars`. Adaptation uses the requested `aggrs`, not a prefix scan of arbitrary column names. Non-count functions currently accept numeric columns; this does not extend `min` or `max` to text/date columns.
|
|
106
|
+
|
|
107
|
+
An empty group query returns `[]`. An aggregate over no matching rows returns `"0"` for counts and `null` for non-count metrics. Missing relation objects retain their existing absence semantics rather than being synthesized into zero-valued summaries. `$Dto.aggregate` and `$Dto.group` reflect these scalar contracts, including nullable non-count fields.
|
|
108
|
+
|
|
109
|
+
`count()` also returns a string (or its existing `undefined` absence value). In `selectAndCount()`, `total` is a string; `pageCount`, `pageSize`, and `pageNo` remain numbers. Convert explicitly at a business arithmetic boundary only when the application's numeric range permits it.
|
|
110
|
+
|
|
111
|
+
Driver strings are preserved exactly, including large integers and decimal precision. Supported finite numeric/bigint scalars are converted to strings; numeric counts must be nonnegative safe integers. Unsupported values and unsafe numeric counts are rejected. Conversion cannot restore precision already lost by a driver; configure driver precision deliberately when that matters. The ORM does not parse decimal strings through JavaScript `Number`.
|
|
112
|
+
|
|
113
|
+
**Compatibility:** drivers that previously leaked native numeric aggregate values now return strings, matching the existing declared string contract. Untyped code that depended on native numbers must adapt deliberately. Non-count TypeScript and DTO fields now explicitly admit SQL `null`; callers must handle it rather than assuming a populated value.
|
|
114
|
+
|
|
115
|
+
## Bounded, client-selectable analysis APIs
|
|
116
|
+
|
|
117
|
+
Raw `model.group()` / `model.aggregate()` remain server-authored ORM operations. Do not expose their full parameter objects directly to clients. For an API that lets clients choose dimensions and metrics, define one `$defineAnalysis(modelLike, config)` policy and attach it to the resource SelectReq DTO with `@Dto({ analysis: policy })`.
|
|
118
|
+
|
|
119
|
+
Use `@Arg.filter(SelectReq, { mode: 'group' })` or `{ mode: 'aggregate' }`, infer finite optional response fields with `$Dto.analysisGroup(policy)` / `$Dto.analysisAggregate(policy)`, and execute through `$executeAnalysisGroup(model, policy, params)` / `$executeAnalysisAggregate(model, policy, params)`. The same policy supplies field allowlists, per-action defaults, limits, request enums and public `filter.capabilities.analysis` metadata. It is an API boundary, not an Entity-wide permission flag.
|
|
120
|
+
|
|
121
|
+
These helpers deliberately expose less than the raw ORM: no joins, relation loading, `having`, custom expressions or aliases. A named filter that requires joins is rejected for analysis rather than silently dropped. Ordinary select retains its existing joined-filter behavior. Projection, order and pagination controls are validated but do not drive analysis execution; dimension ordering and the result cap are server-owned.
|
|
122
|
+
|
|
123
|
+
The default limits are 3 dimensions, 10 function/column metric pairs and 1000 grouped rows. Group execution reads at most the configured row cap plus one and fails with 422 on overflow instead of returning a silently truncated result. This bounds materialized/returned rows, not database scan or aggregation cost. Existing instance scope, soft-delete handling, query caching and scalar normalization remain owned by the ORM.
|
|
124
|
+
|
|
125
|
+
See [CRUD Workflow](/backend/crud-workflow#grouping-and-statistics) for the Student policy, JSON query transport and default CLI analysis generation.
|
|
126
|
+
|
|
95
127
|
## `having` and grouped ordering
|
|
96
128
|
|
|
97
129
|
Grouped queries often need filtering and ordering on derived fields.
|
|
@@ -96,9 +96,45 @@ For query parameters, that distinction matters because plain optional values sti
|
|
|
96
96
|
|
|
97
97
|
## `@Arg.filter`
|
|
98
98
|
|
|
99
|
-
The validation layer
|
|
99
|
+
The validation layer supports query-style inputs through `@Arg.filter`, which ties DTO/query helpers to named filters, raw `where`, transformations, and list controls.
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
### Analysis action modes
|
|
102
|
+
|
|
103
|
+
A DTO can attach one shared `$defineAnalysis` policy through `@Dto({ analysis: studentAnalysis })`. Select, group, and aggregate still have separate contracts:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
async select(
|
|
107
|
+
@Arg.filter(DtoStudentSelectReq) params: IQueryParams<ModelStudent>,
|
|
108
|
+
) {
|
|
109
|
+
return await this.scope.service.student.select(params);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async group(
|
|
113
|
+
@Arg.filter(DtoStudentSelectReq, { mode: 'group' })
|
|
114
|
+
params: TypeAnalysisGroupParams<typeof studentAnalysis>,
|
|
115
|
+
) {
|
|
116
|
+
return await this.scope.service.student.group(params);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async aggregate(
|
|
120
|
+
@Arg.filter(DtoStudentSelectReq, { mode: 'aggregate' })
|
|
121
|
+
params: TypeAnalysisAggregateParams<typeof studentAnalysis>,
|
|
122
|
+
) {
|
|
123
|
+
return await this.scope.service.student.aggregate(params);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Group mode derives `groups` and `aggrs` controls from the policy; aggregate mode derives `aggrs` only. Each action receives its own schema reference and finite enums without mutating the base SelectReq schema. An ordinary select request explicitly containing analysis controls is rejected before unknown-key stripping can hide them. This does not enable strict unknown-key rejection for every unrelated filter.
|
|
128
|
+
|
|
129
|
+
For GET, array/object controls are JSON query values: `groups=["level"]` and `aggrs={"count":["imageId"]}` before URL encoding. Generated API calls accept structured values and encode them through the existing `combineQueries` convention. Malformed JSON, empty arrays/metric objects, duplicate fields, unsupported functions/columns, or any `groups` on aggregate are invalid. Omitted `groups` and `aggrs` independently use the action defaults; explicit empty input never means “use the default.”
|
|
130
|
+
|
|
131
|
+
The policy owns a dimension allowlist and function-specific column allowlists. Clients cannot introduce custom aliases, expressions, joins, or `having`. A named filter that requires a join also fails for analysis, rather than silently dropping the join or changing aggregate multiplicity; ordinary select joined filters remain supported. Named local filters, transforms, and raw-`where` precedence retain their existing behavior.
|
|
132
|
+
|
|
133
|
+
List controls such as `columns`, `orders`, `pageNo`, and `pageSize` remain validated but do not project, sort, or paginate analysis results. For example, invalid `pageSize: 0` still fails, while valid page 2 does not remove matching source rows from the aggregate population. Server-side instance, soft-delete, and business scope must still constrain `where` before execution.
|
|
134
|
+
|
|
135
|
+
`$executeAnalysisGroup(model, policy, params)` and `$executeAnalysisAggregate(model, policy, params)` revalidate analysis controls for direct service callers. The default effective limits are `maxGroups: 3`, `maxMetrics: 10`, and `maxGroupRows: 1000`; policies can deliberately override them. Group execution uses deterministic dimension ordering and reads cap+1 rows to detect overflow. Invalid analysis selections and group-row overflow produce deliberate `422` errors, not generic server failures or silently truncated results. The row cap is not a limit on database scan/aggregation cost.
|
|
136
|
+
|
|
137
|
+
The derived schemas expose public `filter.capabilities.analysis` (mode, allowlists, defaults, limits), including on flattened `groups` / `aggrs` query parameter schemas. They do not publish model/class references. See [CRUD Workflow](/backend/crud-workflow#grouping-and-statistics) and [DTO Infer and Generation](/backend/dto-infer-generation#aggregate-and-group-dto-inference) for policy authoring and requested-only response fields.
|
|
102
138
|
|
|
103
139
|
## Tool groups
|
|
104
140
|
|
|
@@ -138,7 +138,10 @@ function installStudentCreateResponseCapture(page: Page) {
|
|
|
138
138
|
const studentDraftCreatePath = '/admin/rest/resource/training-student%3Astudent/create';
|
|
139
139
|
|
|
140
140
|
function studentDraftNameInput(page: Page) {
|
|
141
|
-
return page
|
|
141
|
+
return page
|
|
142
|
+
.locator('[role="tabpanel"]:not([hidden]):visible')
|
|
143
|
+
.getByRole('group', { name: 'Student Name *', exact: true })
|
|
144
|
+
.getByRole('textbox');
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
function studentDraftBanner(page: Page) {
|
|
@@ -1535,7 +1538,10 @@ test(
|
|
|
1535
1538
|
await expect(page).toHaveURL(
|
|
1536
1539
|
new RegExp(`/training-student(?:%3A|:)student/${students[index]!.id}/edit(?:[?#]|$)`),
|
|
1537
1540
|
);
|
|
1538
|
-
|
|
1541
|
+
const input = studentDraftNameInput(page);
|
|
1542
|
+
// Navigation can finish before the outgoing task's transition.
|
|
1543
|
+
await expect(input).toHaveCount(1);
|
|
1544
|
+
await expect(input).toHaveValue(titles[index]!);
|
|
1539
1545
|
await expect(task(index)).toHaveAttribute('aria-selected', 'true');
|
|
1540
1546
|
};
|
|
1541
1547
|
const expectStrip = async (compact: boolean, count: number) => {
|
|
@@ -1733,6 +1739,7 @@ test(
|
|
|
1733
1739
|
await expect(more).toBeFocused();
|
|
1734
1740
|
await openMenu([0, 2], 0);
|
|
1735
1741
|
await close(0);
|
|
1742
|
+
await expect(studentDraftNameInput(page)).toHaveCount(1);
|
|
1736
1743
|
await expect(studentDraftNameInput(page)).toHaveValue(titles[2]!);
|
|
1737
1744
|
await expect(task(2)).toHaveAttribute('aria-selected', 'true');
|
|
1738
1745
|
await openMenu([2], 2);
|
|
@@ -37,19 +37,13 @@
|
|
|
37
37
|
"format": "oxfmt --check",
|
|
38
38
|
"format:fix": "oxfmt --write",
|
|
39
39
|
"lint": "oxlint",
|
|
40
|
-
"lint:fix": "oxlint --fix"
|
|
41
|
-
"docs:fix": "textlint --fix ./packages-docs/**/*.md"
|
|
40
|
+
"lint:fix": "oxlint --fix"
|
|
42
41
|
},
|
|
43
42
|
"dependencies": {
|
|
44
43
|
"vona": "workspace:^"
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
46
|
"@cabloy/lint": "workspace:^",
|
|
48
|
-
"textlint": "^15.7.0",
|
|
49
|
-
"textlint-rule-en-no-mixed-period": "^3.0.2",
|
|
50
|
-
"textlint-rule-ja-no-mixed-period": "^3.0.2",
|
|
51
|
-
"textlint-rule-ja-space-between-half-and-full-width": "^2.4.2",
|
|
52
|
-
"textlint-rule-period-in-list-item": "^1.0.1",
|
|
53
47
|
"oxfmt": "^0.74.0",
|
|
54
48
|
"oxlint": "^1.89.0"
|
|
55
49
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
|
|
2
|
+
|
|
3
|
+
import { $Dto } from 'vona-module-a-orm';
|
|
4
|
+
import { Dto } from 'vona-module-a-web';
|
|
5
|
+
|
|
6
|
+
import { <%=argv.resourceName%>Analysis } from '../lib/<%=argv.resourceName%>Analysis.ts';
|
|
7
|
+
|
|
8
|
+
export interface IDtoOptions<%=argv.resourceNameCapitalize%>AggregateRes extends IDecoratorDtoOptions {}
|
|
9
|
+
|
|
10
|
+
@Dto<IDtoOptions<%=argv.resourceNameCapitalize%>AggregateRes>()
|
|
11
|
+
export class Dto<%=argv.resourceNameCapitalize%>AggregateRes extends $Dto.analysisAggregate(<%=argv.resourceName%>Analysis) {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { $defineAnalysis } from 'vona-module-a-orm';
|
|
2
|
+
|
|
3
|
+
import { Model<%=argv.resourceNameCapitalize%> } from '../model/<%=argv.resourceName%>.ts';
|
|
4
|
+
|
|
5
|
+
// One allowlist drives request controls, response fields, and bounded execution.
|
|
6
|
+
export const <%=argv.resourceName%>Analysis = $defineAnalysis(() => Model<%=argv.resourceNameCapitalize%>, {
|
|
7
|
+
groups: ['name'],
|
|
8
|
+
aggrs: { count: ['*'] },
|
|
9
|
+
defaults: {
|
|
10
|
+
group: { groups: ['name'], aggrs: { count: ['*'] } },
|
|
11
|
+
aggregate: { aggrs: { count: ['*'] } },
|
|
12
|
+
},
|
|
13
|
+
limits: { maxGroups: 3, maxMetrics: 10, maxGroupRows: 1000 },
|
|
14
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import { app } from 'vona-mock';
|
|
5
|
+
|
|
6
|
+
import { <%=argv.resourceName%>Analysis } from '../src/lib/<%=argv.resourceName%>Analysis.ts';
|
|
7
|
+
|
|
8
|
+
describe('<%=argv.resourceName%>Analysis.test.ts', { concurrency: false }, () => {
|
|
9
|
+
it('returns default and requested-only string counts with validated controls', async () => {
|
|
10
|
+
await app.bean.executor.mockCtx(async () => {
|
|
11
|
+
const scope = app.scope('<%=argv.moduleInfo.relativeName%>');
|
|
12
|
+
const name = `__analysis_${randomUUID()}`;
|
|
13
|
+
const fixture = await scope.model.<%=argv.resourceName%>.insert({ name });
|
|
14
|
+
try {
|
|
15
|
+
await app.bean.passport.signinMock();
|
|
16
|
+
const queries = [
|
|
17
|
+
{ name },
|
|
18
|
+
// List controls are validated but do not paginate the analysis population.
|
|
19
|
+
{ where: { id: fixture.id }, columns: ['id'], orders: 'name,desc', pageNo: 2, pageSize: 1 },
|
|
20
|
+
];
|
|
21
|
+
for (const query of queries) {
|
|
22
|
+
const groups = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/group', { query });
|
|
23
|
+
assert.deepEqual(groups, [{ name, count_all: '1' }]);
|
|
24
|
+
assert.deepEqual(await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/group', {
|
|
25
|
+
query: { ...query, groups: JSON.stringify(['name']), aggrs: JSON.stringify({ count: ['*'] }) },
|
|
26
|
+
}), groups);
|
|
27
|
+
assert.deepEqual(await scope.service.<%=argv.resourceName%>.group({
|
|
28
|
+
where: { id: fixture.id }, groups: ['name'], aggrs: { count: ['*'] },
|
|
29
|
+
}), groups);
|
|
30
|
+
const aggregate = await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/aggregate', { query });
|
|
31
|
+
assert.deepEqual(aggregate, { count_all: '1' });
|
|
32
|
+
assert.deepEqual(await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/aggregate', {
|
|
33
|
+
query: { ...query, aggrs: JSON.stringify({ count: ['*'] }) },
|
|
34
|
+
}), aggregate);
|
|
35
|
+
assert.deepEqual(await scope.service.<%=argv.resourceName%>.aggregate({
|
|
36
|
+
where: { id: fixture.id }, aggrs: { count: ['*'] },
|
|
37
|
+
}), aggregate);
|
|
38
|
+
}
|
|
39
|
+
for (const action of ['group', 'aggregate'] as const) {
|
|
40
|
+
for (const controls of [
|
|
41
|
+
{ pageNo: 2, pageSize: 0 },
|
|
42
|
+
{ aggrs: JSON.stringify({}) },
|
|
43
|
+
{ aggrs: JSON.stringify({ count: ['*', '*'] }) },
|
|
44
|
+
{ aggrs: JSON.stringify({ count: ['unknown'] }) },
|
|
45
|
+
{ aggrs: '{invalid-json' },
|
|
46
|
+
]) {
|
|
47
|
+
await assert.rejects(() => app.bean.executor.performAction('get', `<%=argv.moduleActionPathRaw%>/${action}`, {
|
|
48
|
+
query: { name, ...controls },
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The shared policy does not enable analysis controls on ordinary select.
|
|
53
|
+
await assert.rejects(() => app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>', {
|
|
54
|
+
query: { name, aggrs: JSON.stringify({ count: ['*'] }) },
|
|
55
|
+
}));
|
|
56
|
+
for (const groups of [[], ['name', 'name'], ['description'], ['unknown']]) {
|
|
57
|
+
await assert.rejects(() => app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/group', {
|
|
58
|
+
query: { name, groups: JSON.stringify(groups) },
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
await assert.rejects(() => scope.service.<%=argv.resourceName%>.group({ aggrs: { count: [] } }));
|
|
62
|
+
assert.deepEqual(<%=argv.resourceName%>Analysis.defaults.group, {
|
|
63
|
+
groups: ['name'], aggrs: { count: ['*'] },
|
|
64
|
+
});
|
|
65
|
+
await assert.rejects(() => app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/aggregate', {
|
|
66
|
+
query: { name, groups: JSON.stringify(['name']) },
|
|
67
|
+
}));
|
|
68
|
+
await assert.rejects(() => scope.service.<%=argv.resourceName%>.aggregate({ aggrs: { count: [] } }));
|
|
69
|
+
assert.deepEqual(<%=argv.resourceName%>Analysis.defaults.aggregate, { aggrs: { count: ['*'] } });
|
|
70
|
+
await scope.model.<%=argv.resourceName%>.deleteById(fixture.id);
|
|
71
|
+
for (const query of queries) {
|
|
72
|
+
assert.deepEqual(await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/group', { query }), []);
|
|
73
|
+
assert.deepEqual(await app.bean.executor.performAction('get', '<%=argv.moduleActionPathRaw%>/aggregate', { query }), { count_all: '0' });
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
await scope.model.<%=argv.resourceName%>.deleteById(fixture.id, { disableDeleted: true });
|
|
77
|
+
await app.bean.passport.signout();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IDecoratorDtoOptions } from 'vona-module-a-web';
|
|
2
|
+
|
|
3
|
+
import { $Dto } from 'vona-module-a-orm';
|
|
4
|
+
import { Dto } from 'vona-module-a-web';
|
|
5
|
+
|
|
6
|
+
import { <%=argv.resourceName%>Analysis } from '../lib/<%=argv.resourceName%>Analysis.ts';
|
|
7
|
+
|
|
8
|
+
export interface IDtoOptions<%=argv.resourceNameCapitalize%>GroupResItem extends IDecoratorDtoOptions {}
|
|
9
|
+
|
|
10
|
+
@Dto<IDtoOptions<%=argv.resourceNameCapitalize%>GroupResItem>()
|
|
11
|
+
export class Dto<%=argv.resourceNameCapitalize%>GroupResItem extends $Dto.analysisGroup(<%=argv.resourceName%>Analysis) {}
|