cabloy 5.1.162 → 5.1.164

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 (96) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +2 -0
  4. package/package.json +1 -1
  5. package/repo-docs/backend/shared-rbac-architecture.md +4 -0
  6. package/repo-docs/blogs/ai-spec-driven-development/cover-en-v1.png +0 -0
  7. package/repo-docs/blogs/ai-spec-driven-development/implementation-burndown.png +0 -0
  8. package/repo-docs/blogs/ai-spec-driven-development/implementation-gantt.png +0 -0
  9. package/repo-docs/blogs/ai-spec-driven-development/index.md +217 -0
  10. package/repo-docs/blogs/index.md +15 -0
  11. package/repo-docs/editions/overview.md +2 -0
  12. package/repo-docs/frontend/environment-config-guide.md +43 -10
  13. package/repo-docs/frontend/resource-list-page-deep-dive.md +14 -1
  14. package/repo-docs/frontend/table-action-visibility-permission-flow-guide.md +20 -0
  15. package/repo-docs/frontend/table-guide.md +42 -0
  16. package/repo-docs/frontend/table-resource-crud-cookbook.md +25 -1
  17. package/repo-e2e/docs/specs/blogs-index.spec.ts +45 -9
  18. package/repo-e2e/specs/cabloy-basic.spec.ts +249 -2
  19. package/vona/packages-cli/cli/package.json +1 -1
  20. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +17 -6
  21. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}DeleteBulk.tsx_ +14 -0
  22. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +7 -1
  23. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/service/{{resourceName}}.ts_ +6 -0
  24. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +12 -1
  25. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +12 -1
  26. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +8 -0
  27. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}DeleteBulk.tsx_ +14 -0
  28. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +7 -1
  29. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/service/{{resourceName}}.ts_ +6 -0
  30. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/3-en-us.ts +1 -0
  31. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/4-zh-cn.ts +1 -0
  32. package/vona/packages-cli/cli-set-api/package.json +1 -1
  33. package/vona/packages-vona/vona/package.json +1 -1
  34. package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +9 -0
  35. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +1 -0
  36. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +1 -0
  37. package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +9 -0
  38. package/vona/src/suite/a-training/modules/training-student/src/dto/studentDeleteBulk.tsx +14 -0
  39. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +7 -1
  40. package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +6 -0
  41. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +114 -2
  42. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  43. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/index.ts +24 -3
  44. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/locales.ts +18 -0
  45. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacResourceBulk.ts +50 -0
  46. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/index.ts +1 -0
  47. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/errors.ts +18 -0
  48. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/locale/en-us.ts +6 -0
  49. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/locale/zh-cn.ts +6 -0
  50. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/index.ts +1 -0
  51. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/test/resourceBulk.test.ts +128 -0
  52. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  53. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  54. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  55. package/zova/packages-cli/cli/package.json +2 -2
  56. package/zova/packages-cli/cli-set-front/cli/templates/create/component/boilerplateTableActionBulk/controller.tsx_ +2 -2
  57. package/zova/packages-cli/cli-set-front/package.json +1 -1
  58. package/zova/packages-zova/zova/package.json +2 -2
  59. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +16 -12
  60. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +66 -7
  61. package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +18 -0
  62. package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +5 -0
  63. package/zova/src/suite/cabloy-basic/modules/basic-commands/src/.metadata/index.ts +16 -0
  64. package/zova/src/suite/cabloy-basic/modules/basic-commands/src/bean/command.deleteBulk.tsx +35 -0
  65. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +143 -7
  66. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +3 -2
  67. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockToolbarBulk/controller.tsx +76 -4
  68. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/en-us.ts +7 -0
  69. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/zh-cn.ts +6 -0
  70. package/zova/src/suite/cabloy-basic/modules/basic-page/src/lib/selection.ts +84 -0
  71. package/zova/src/suite/cabloy-basic/modules/basic-page/test/lib/selection.test.ts +99 -0
  72. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/component/actionDeleteBulk.ts +31 -0
  73. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/index.ts +13 -0
  74. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/actionCreate/controller.tsx +5 -2
  75. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/actionDeleteBulk/controller.tsx +57 -0
  76. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +35 -7
  77. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/en-us.ts +4 -0
  78. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/zh-cn.ts +4 -0
  79. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  80. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/selectedIds.ts +15 -0
  81. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +57 -0
  82. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/lib/selectedIds.test.ts +20 -0
  83. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  84. package/zova/src/suite-vendor/a-zova/modules/a-command/package.json +1 -1
  85. package/zova/src/suite-vendor/a-zova/modules/a-command/src/lib/beanCommandSelectedBase.ts +33 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-command/src/lib/index.ts +1 -0
  87. package/zova/src/suite-vendor/a-zova/modules/a-command/src/types/command.ts +5 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-command/test/lib/beanCommandSelectedBase.test.ts +47 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  90. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/action.ts +3 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionBulk.ts +19 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  93. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +86 -26
  94. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +30 -6
  95. package/zova/src/suite-vendor/a-zova/modules/a-table/src/types/table.ts +2 -0
  96. package/zova/src/suite-vendor/a-zova/package.json +4 -4
@@ -0,0 +1,14 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
3
+
4
+ import { Api, v } from 'vona-module-a-openapiutils';
5
+ import { Dto } from 'vona-module-a-web';
6
+ import { z } from 'zod';
7
+
8
+ export interface IDtoOptionsStudentDeleteBulk extends IDecoratorDtoOptions {}
9
+
10
+ @Dto<IDtoOptionsStudentDeleteBulk>()
11
+ export class DtoStudentDeleteBulk {
12
+ @Api.field(v.required(), z.array(v.tableIdentity()()).min(1).max(100))
13
+ ids: TableIdentity[];
14
+ }
@@ -40,7 +40,13 @@ export interface IDtoOptionsStudentSelectResItem extends IDecoratorDtoOptions {}
40
40
  ],
41
41
  }),
42
42
  ZovaRender.block('basic-page:blockToolbarBulk', {
43
- actions: [ZovaRender.tableActionBulk('basic-table:actionCreate')],
43
+ actions: [
44
+ ZovaRender.tableActionBulk('basic-table:actionCreate'),
45
+ ZovaRender.tableActionBulk('basic-table:actionDeleteBulk', {
46
+ requiresSelection: true,
47
+ selectedMaxIds: 100,
48
+ }),
49
+ ],
44
50
  }),
45
51
  ZovaRender.block('basic-page:blockTable'),
46
52
  ZovaRender.block('basic-page:blockPager'),
@@ -3,6 +3,7 @@ import type { IQueryParams } from 'vona-module-a-orm';
3
3
 
4
4
  import { BeanBase } from 'vona';
5
5
  import { Service } from 'vona-module-a-bean';
6
+ import { Core } from 'vona-module-a-core';
6
7
 
7
8
  import type { DtoStudentCreate } from '../dto/studentCreate.tsx';
8
9
  import type { DtoStudentSelectRes } from '../dto/studentSelectRes.tsx';
@@ -86,6 +87,11 @@ export class ServiceStudent extends BeanBase {
86
87
  });
87
88
  }
88
89
 
90
+ @Core.transaction()
91
+ async deleteBulk(ids: TableIdentity[]): Promise<void> {
92
+ await this.scope.model.student.deleteBulk(ids, { include: getStudentRelationsInclude() });
93
+ }
94
+
89
95
  async deleteForce(id: TableIdentity) {
90
96
  return await this.scope.model.student.deleteById(id, {
91
97
  disableDeleted: true,
@@ -64,13 +64,47 @@ describe('student.test.ts', { concurrency: false }, () => {
64
64
  });
65
65
  });
66
66
 
67
+ it('action:student:bulkDeleteOpenapi', async () => {
68
+ await app.bean.executor.mockCtx(async () => {
69
+ const controller = app.bean.onion.controller
70
+ .getOnionsEnabledCached()
71
+ .find(item => item.beanOptions.beanFullName === 'training-student.controller.student')
72
+ ?.beanOptions.beanClass;
73
+ if (!controller) throw new Error('training-student.controller.student not found');
74
+ const apiJson = await app.bean.openapi.generateJsonOfControllerAction(
75
+ controller,
76
+ 'deleteBulk',
77
+ 'V31',
78
+ );
79
+ const operation = apiJson.paths?.['/api/training/student/bulk/delete']?.post;
80
+ assert.equal(operation?.operationId, 'TrainingStudent_deleteBulk');
81
+ assert.equal(operation?.summary?.toJSON(), 'Bulk Delete');
82
+ const requestSchema = operation?.requestBody?.content?.['application/json']?.schema as any;
83
+ const requestSchemaRef = requestSchema?.$ref;
84
+ assert.ok(requestSchemaRef);
85
+ const requestSchemaName = requestSchemaRef.replace('#/components/schemas/', '');
86
+ const component = apiJson.components?.schemas?.[requestSchemaName] as any;
87
+ assert.equal(component?.properties?.ids?.minItems, 1);
88
+ assert.equal(component?.properties?.ids?.maxItems, 100);
89
+ });
90
+ });
91
+
67
92
  it('action:student:filterFormLayoutMetadata', async () => {
68
93
  await app.bean.executor.mockCtx(async () => {
69
94
  const apiJson = await app.bean.openapi.generateJsonOfClass(DtoStudentSelectResItem);
70
95
  const component = Object.values(apiJson.components!.schemas as any).find(item => {
71
96
  return (item as any).properties?._operationsRow;
72
97
  });
73
- const filterBlock = (component as any)?.rest?.blocks?.[0]?.options?.blocks?.[0];
98
+ const pageBlocks = (component as any)?.rest?.blocks?.[0]?.options?.blocks;
99
+ const toolbarBlock = pageBlocks?.[1];
100
+ assert.equal(toolbarBlock?.render, 'basic-page:blockToolbarBulk');
101
+ const deleteBulkAction = toolbarBlock?.options?.actions?.find(
102
+ (action: any) => action.render === 'basic-table:actionDeleteBulk',
103
+ );
104
+ assert.equal(deleteBulkAction?.options?.requiresSelection, true);
105
+ assert.equal(deleteBulkAction?.options?.selectedMaxIds, 100);
106
+
107
+ const filterBlock = pageBlocks?.[0];
74
108
  assert.equal(filterBlock?.render, 'basic-page:blockFilter');
75
109
  assert.equal(filterBlock?.options?.formFieldLayout?.inline, true);
76
110
  assert.deepEqual(
@@ -449,6 +483,75 @@ describe('student.test.ts', { concurrency: false }, () => {
449
483
  });
450
484
  });
451
485
 
486
+ it('action:student:deleteBulk', async () => {
487
+ await app.bean.executor.mockCtx(async () => {
488
+ await app.bean.passport.signinMock();
489
+ const studentIds: string[] = [];
490
+ try {
491
+ for (const name of ['__DeleteBulkA__', '__DeleteBulkB__']) {
492
+ const studentId = await app.bean.executor.performAction('post', '/training/student', {
493
+ body: { name, mobile: `138${studentIds.length}1234567`, level: 1 },
494
+ });
495
+ studentIds.push(studentId);
496
+ }
497
+ const deleted = await app.bean.executor.performAction(
498
+ 'post',
499
+ '/training/student/bulk/delete',
500
+ { body: { ids: studentIds } },
501
+ );
502
+ assert.equal(deleted, null);
503
+ for (const studentId of studentIds) {
504
+ assert.equal(
505
+ await app.scope('training-student').model.student.getById(studentId),
506
+ undefined,
507
+ );
508
+ }
509
+ } finally {
510
+ for (const studentId of studentIds.reverse()) {
511
+ await app
512
+ .scope('training-student')
513
+ .model.studentContent.delete({ studentId }, { disableDeleted: true });
514
+ await app
515
+ .scope('training-student')
516
+ .model.student.deleteById(studentId, { disableDeleted: true });
517
+ }
518
+ await app.bean.passport.signout();
519
+ }
520
+ });
521
+ });
522
+
523
+ it('action:student:deleteBulk accepts duplicate and missing IDs', async () => {
524
+ await app.bean.executor.mockCtx(async () => {
525
+ await app.bean.passport.signinMock();
526
+ let studentId: string | undefined;
527
+ try {
528
+ studentId = await app.bean.executor.performAction('post', '/training/student', {
529
+ body: { name: '__DeleteBulkIdempotent__', mobile: '13891234567', level: 1 },
530
+ });
531
+ const deleted = await app.bean.executor.performAction(
532
+ 'post',
533
+ '/training/student/bulk/delete',
534
+ { body: { ids: [studentId, studentId, '__missing-student__'] } },
535
+ );
536
+ assert.equal(deleted, null);
537
+ assert.equal(
538
+ await app.scope('training-student').model.student.getById(studentId),
539
+ undefined,
540
+ );
541
+ } finally {
542
+ if (studentId) {
543
+ await app
544
+ .scope('training-student')
545
+ .model.studentContent.delete({ studentId }, { disableDeleted: true });
546
+ await app
547
+ .scope('training-student')
548
+ .model.student.deleteById(studentId, { disableDeleted: true });
549
+ }
550
+ await app.bean.passport.signout();
551
+ }
552
+ });
553
+ });
554
+
452
555
  it('action:student:contentPersistence', async () => {
453
556
  await app.bean.executor.mockCtx(async () => {
454
557
  await app.bean.passport.signinMock();
@@ -531,7 +634,16 @@ describe('student.test.ts', { concurrency: false }, () => {
531
634
  await app.bean.passport.signinMock();
532
635
  try {
533
636
  app.bean.passport.current!.roles = [];
534
- const actions = ['create', 'select', 'view', 'update', 'summary', 'delete', 'deleteForce'];
637
+ const actions = [
638
+ 'create',
639
+ 'select',
640
+ 'view',
641
+ 'update',
642
+ 'summary',
643
+ 'delete',
644
+ 'deleteBulk',
645
+ 'deleteForce',
646
+ ];
535
647
  const permissions = await Promise.all(
536
648
  actions.map(action =>
537
649
  app.bean.permission.retrievePermissionAction('training-student:student', action),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-rbac",
3
- "version": "5.0.5",
3
+ "version": "5.0.6",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -27,6 +27,7 @@ declare module 'vona-module-a-rbac' {
27
27
  /** guard: end */
28
28
  /** bean: begin */
29
29
  export * from '../bean/bean.rbacCatalog.ts';
30
+ export * from '../bean/bean.rbacResourceBulk.ts';
30
31
  export * from '../bean/bean.rbacScope.ts';
31
32
 
32
33
  import 'vona';
@@ -41,6 +42,11 @@ declare module 'vona-module-a-rbac' {
41
42
  get scope(): ScopeModuleARbac;
42
43
  }
43
44
 
45
+ export interface BeanRbacResourceBulk {
46
+ /** @internal */
47
+ get scope(): ScopeModuleARbac;
48
+ }
49
+
44
50
  export interface BeanRbacScope {
45
51
  /** @internal */
46
52
  get scope(): ScopeModuleARbac;
@@ -49,11 +55,13 @@ declare module 'vona-module-a-rbac' {
49
55
  /** bean: end */
50
56
  /** bean: begin */
51
57
  import type { BeanRbacCatalog } from '../bean/bean.rbacCatalog.ts';
58
+ import type { BeanRbacResourceBulk } from '../bean/bean.rbacResourceBulk.ts';
52
59
  import type { BeanRbacScope } from '../bean/bean.rbacScope.ts';
53
60
  import 'vona';
54
61
  declare module 'vona' {
55
62
  export interface IBeanRecordGlobal {
56
63
  'rbacCatalog': BeanRbacCatalog;
64
+ 'rbacResourceBulk': BeanRbacResourceBulk;
57
65
  'rbacScope': BeanRbacScope;
58
66
  }
59
67
  }
@@ -139,8 +147,15 @@ declare module 'vona-module-a-rbac' {
139
147
  export * from '../config/config.ts';
140
148
  import type { config } from '../config/config.ts';
141
149
  /** config: end */
150
+ /** locale: begin */
151
+ import { locales } from './locales.ts';
152
+ /** locale: end */
153
+ /** error: begin */
154
+ export * from '../config/errors.ts';
155
+ import type { errors } from '../config/errors.ts';
156
+ /** error: end */
142
157
  /** scope: begin */
143
- import { BeanScopeBase, type BeanScopeUtil, type TypeModuleConfig } from 'vona';
158
+ import { BeanScopeBase, type BeanScopeUtil, type TypeModuleConfig, type TypeModuleErrors, type TypeModuleLocales, type TypeLocaleBase } from 'vona';
144
159
  import { Scope } from 'vona-module-a-bean';
145
160
 
146
161
  @Scope()
@@ -149,6 +164,8 @@ export class ScopeModuleARbac extends BeanScopeBase {}
149
164
  export interface ScopeModuleARbac {
150
165
  util: BeanScopeUtil;
151
166
  config: TypeModuleConfig<typeof config>;
167
+ error: TypeModuleErrors<typeof errors>;
168
+ locale: TypeModuleLocales<(typeof locales)[TypeLocaleBase]>;
152
169
  event: IModuleEvent;
153
170
  }
154
171
 
@@ -166,8 +183,12 @@ declare module 'vona' {
166
183
  'a-rbac': ReturnType<typeof config>;
167
184
  }
168
185
 
186
+ export interface IBeanScopeLocale {
187
+ 'a-rbac': (typeof locales)[TypeLocaleBase];
188
+ }
169
189
 
170
-
171
-
190
+ export interface IBeanScopeErrors {
191
+ 'a-rbac': typeof errors;
192
+ }
172
193
  }
173
194
  /** scope: end */
@@ -0,0 +1,18 @@
1
+ import type { TypeLocaleBase } from 'vona';
2
+
3
+ import { $makeLocaleMagic } from 'vona';
4
+
5
+ import locale_en_us from '../config/locale/en-us.ts';
6
+ import locale_zh_cn from '../config/locale/zh-cn.ts';
7
+
8
+ export const locales = {
9
+ 'en-us': locale_en_us,
10
+ 'zh-cn': locale_zh_cn,
11
+ };
12
+
13
+ export function $locale<K extends keyof (typeof locales)[TypeLocaleBase]>(
14
+ key: K,
15
+ ...args: any[]
16
+ ): any {
17
+ return $makeLocaleMagic(`a-rbac::${key}`, ...args);
18
+ }
@@ -0,0 +1,50 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { BeanBase } from 'vona';
4
+ import { Bean } from 'vona-module-a-bean';
5
+
6
+ import type { IRbacScopeAccess } from '../types/scope.ts';
7
+
8
+ export type TypeRbacResourceBulkLoad<Entry extends { id: TableIdentity }> = (
9
+ ids: readonly TableIdentity[],
10
+ ) => Promise<Entry[]>;
11
+
12
+ @Bean()
13
+ export class BeanRbacResourceBulk extends BeanBase {
14
+ async entries<Entry extends { id: TableIdentity }>(
15
+ ids: readonly TableIdentity[],
16
+ load: TypeRbacResourceBulkLoad<Entry>,
17
+ access: IRbacScopeAccess,
18
+ ): Promise<Entry[]> {
19
+ const requested = this.validateIds(ids);
20
+ const entries = await load(requested);
21
+ const entriesByKey = new Map(entries.map(entry => [this.idKey(entry.id), entry]));
22
+ if (
23
+ entries.length !== requested.length ||
24
+ requested.some(id => !entriesByKey.has(this.idKey(id)))
25
+ ) {
26
+ this.scope.error.ResourceBulkEntriesMissing.throw();
27
+ }
28
+ const orderedEntries = requested.map(id => entriesByKey.get(this.idKey(id))!);
29
+ access.checkEntries(orderedEntries);
30
+ return orderedEntries;
31
+ }
32
+
33
+ private validateIds(ids: readonly TableIdentity[]): TableIdentity[] {
34
+ if (ids.length === 0) this.scope.error.ResourceBulkIdsEmpty.throw();
35
+ const keys = new Set<string>();
36
+ for (const id of ids) {
37
+ const key = this.idKey(id);
38
+ if (keys.has(key)) this.scope.error.ResourceBulkIdsDuplicate.throw();
39
+ keys.add(key);
40
+ }
41
+ return [...ids];
42
+ }
43
+
44
+ private idKey(id: TableIdentity): string {
45
+ if (id === undefined || id === null || id === '') {
46
+ this.scope.error.ResourceBulkIdsInvalid.throw();
47
+ }
48
+ return String(id);
49
+ }
50
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './bean.rbacCatalog.ts';
2
+ export * from './bean.rbacResourceBulk.ts';
2
3
  export * from './bean.rbacScope.ts';
3
4
  export * from './event.policyInvalidated.ts';
4
5
  export * from './event.resolvePolicy.ts';
@@ -0,0 +1,18 @@
1
+ export const errors = {
2
+ ResourceBulkIdsEmpty: {
3
+ code: 1001,
4
+ status: 422,
5
+ },
6
+ ResourceBulkIdsInvalid: {
7
+ code: 1003,
8
+ status: 422,
9
+ },
10
+ ResourceBulkIdsDuplicate: {
11
+ code: 1004,
12
+ status: 422,
13
+ },
14
+ ResourceBulkEntriesMissing: {
15
+ code: 1005,
16
+ status: 404,
17
+ },
18
+ } as const;
@@ -0,0 +1,6 @@
1
+ export default {
2
+ ResourceBulkIdsEmpty: 'Bulk resource IDs are required',
3
+ ResourceBulkIdsInvalid: 'Bulk resource IDs cannot contain empty values',
4
+ ResourceBulkIdsDuplicate: 'Bulk resource IDs cannot contain duplicates',
5
+ ResourceBulkEntriesMissing: 'One or more bulk resource entries are absent',
6
+ };
@@ -0,0 +1,6 @@
1
+ export default {
2
+ ResourceBulkIdsEmpty: '批量资源 ID 不能为空',
3
+ ResourceBulkIdsInvalid: '批量资源 ID 不能包含空值',
4
+ ResourceBulkIdsDuplicate: '批量资源 ID 不能包含重复值',
5
+ ResourceBulkEntriesMissing: '一个或多个批量资源条目不存在',
6
+ };
@@ -1,3 +1,4 @@
1
+ export * from './.metadata/locales.ts';
1
2
  export * from './lib/index.ts';
2
3
  export * from './types/index.ts';
3
4
  export * from './.metadata/index.ts';
@@ -0,0 +1,128 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import assert from 'node:assert';
4
+ import { describe, it } from 'node:test';
5
+ import { app } from 'vona-mock';
6
+
7
+ import type { IRbacScopeAccess } from '../src/types/scope.ts';
8
+
9
+ function createAccess(
10
+ onCheck?: (entries: readonly { id: TableIdentity }[]) => void,
11
+ ): IRbacScopeAccess {
12
+ return {
13
+ action: {} as never,
14
+ decision: {} as never,
15
+ unrestricted: false,
16
+ ownerValues: () => ({}),
17
+ where: () => undefined,
18
+ checkEntry: () => {},
19
+ checkEntries: entries => onCheck?.(entries as readonly { id: TableIdentity }[]),
20
+ permissionProjection: () => ({ key: '', allowed: true, matcher: { mode: 'all' } }),
21
+ };
22
+ }
23
+
24
+ function assertScopedError(errorName: string, status: number) {
25
+ return (error: Error) => error.code === `a-rbac:${errorName}` && error.status === status;
26
+ }
27
+
28
+ describe('resourceBulk.test.ts', { concurrency: false }, () => {
29
+ it('rejects invalid IDs before loading with scoped errors', async () => {
30
+ await app.bean.executor.mockCtx(async () => {
31
+ let loads = 0;
32
+ const load = async () => {
33
+ loads += 1;
34
+ return [];
35
+ };
36
+ await assert.rejects(
37
+ () => app.bean.rbacResourceBulk.entries([], load, createAccess()),
38
+ assertScopedError('1001', 422),
39
+ );
40
+ await assert.rejects(
41
+ () => app.bean.rbacResourceBulk.entries([undefined as never], load, createAccess()),
42
+ assertScopedError('1003', 422),
43
+ );
44
+ await assert.rejects(
45
+ () => app.bean.rbacResourceBulk.entries(['1', '1'], load, createAccess()),
46
+ assertScopedError('1004', 422),
47
+ );
48
+ assert.equal(loads, 0);
49
+ });
50
+ });
51
+
52
+ it('requires full coverage and preserves requested order for authorization', async () => {
53
+ await app.bean.executor.mockCtx(async () => {
54
+ let checked: readonly { id: TableIdentity }[] | undefined;
55
+ const entries = await app.bean.rbacResourceBulk.entries(
56
+ ['2', '1'],
57
+ async () => [{ id: '1' }, { id: '2' }],
58
+ createAccess(value => {
59
+ checked = value;
60
+ }),
61
+ );
62
+ assert.deepEqual(
63
+ entries.map(entry => entry.id),
64
+ ['2', '1'],
65
+ );
66
+ assert.deepEqual(
67
+ checked?.map(entry => entry.id),
68
+ ['2', '1'],
69
+ );
70
+
71
+ await assert.rejects(
72
+ () =>
73
+ app.bean.rbacResourceBulk.entries(['1', '2'], async () => [{ id: '1' }], createAccess()),
74
+ assertScopedError('1005', 404),
75
+ );
76
+ });
77
+ });
78
+
79
+ it('runs authorization only after complete scoped loading', async () => {
80
+ await app.bean.executor.mockCtx(async () => {
81
+ let calls = 0;
82
+ let loads = 0;
83
+ await assert.rejects(
84
+ () =>
85
+ app.bean.rbacResourceBulk.entries(
86
+ ['1', '2'],
87
+ async () => {
88
+ loads += 1;
89
+ return [{ id: '1' }];
90
+ },
91
+ createAccess(() => {
92
+ calls += 1;
93
+ }),
94
+ ),
95
+ assertScopedError('1005', 404),
96
+ );
97
+ assert.equal(loads, 1);
98
+ assert.equal(calls, 0);
99
+
100
+ await assert.rejects(
101
+ () =>
102
+ app.bean.rbacResourceBulk.entries(
103
+ ['1'],
104
+ async () => [{ id: '1' }],
105
+ createAccess(() => {
106
+ throw new Error('forbidden');
107
+ }),
108
+ ),
109
+ /forbidden/,
110
+ );
111
+ });
112
+ });
113
+
114
+ it('localizes scoped errors', async () => {
115
+ await app.bean.executor.mockCtx(
116
+ async () => {
117
+ await assert.rejects(
118
+ () => app.bean.rbacResourceBulk.entries([], async () => [], createAccess()),
119
+ (error: Error) =>
120
+ error.code === 'a-rbac:1001' &&
121
+ error.status === 422 &&
122
+ error.message === '批量资源 ID 不能为空',
123
+ );
124
+ },
125
+ { locale: 'zh-cn' },
126
+ );
127
+ });
128
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.51",
3
+ "version": "5.1.52",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "vona-module-a-cabloy": "workspace:^",
11
11
  "vona-module-a-markdown": "^5.0.3",
12
- "vona-module-a-rbac": "^5.0.5",
12
+ "vona-module-a-rbac": "^5.0.6",
13
13
  "vona-module-a-socket": "workspace:^",
14
14
  "vona-module-a-ssr": "workspace:^",
15
15
  "vona-module-a-ssrhmr": "workspace:^"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.21",
3
+ "version": "5.1.22",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-vona",
3
- "version": "5.1.85",
3
+ "version": "5.1.86",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-cli",
3
- "version": "1.2.106",
3
+ "version": "1.2.107",
4
4
  "gitHead": "6f675a8cc46d596142c591c28a40cc4d82fcc6cc",
5
5
  "description": "zova cli",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "@cabloy/process-helper": "^3.1.8",
45
45
  "fs-extra": "^11.3.5",
46
46
  "semver": "^7.6.2",
47
- "zova-cli-set-front": "^1.2.104"
47
+ "zova-cli-set-front": "^1.2.105"
48
48
  },
49
49
  "devDependencies": {
50
50
  "clean-package": "^2.2.0",
@@ -1,7 +1,7 @@
1
1
  import type { IComponentOptions } from 'zova';
2
2
  import type {
3
3
  IJsxRenderContextPage,
4
- IResourceTableActionBulkOptionsBase,
4
+ IResourceTableActionBulkPropsBase,
5
5
  } from 'zova-module-a-openapi';
6
6
 
7
7
  import { BeanControllerBase, Use } from 'zova';
@@ -13,7 +13,7 @@ declare module 'zova-module-a-openapi' {
13
13
  }
14
14
  }
15
15
 
16
- export interface Controller<%=argv.nameMeta.fullCapitalize%>Props extends IResourceTableActionBulkOptionsBase {}
16
+ export interface Controller<%=argv.nameMeta.fullCapitalize%>Props extends IResourceTableActionBulkPropsBase {}
17
17
 
18
18
  @Controller()
19
19
  export class Controller<%=argv.nameMeta.fullCapitalize%> extends BeanControllerBase {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-cli-set-front",
3
- "version": "1.2.104",
3
+ "version": "1.2.105",
4
4
  "gitHead": "6f675a8cc46d596142c591c28a40cc4d82fcc6cc",
5
5
  "description": "zova cli-set-front",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova",
3
- "version": "5.1.156",
3
+ "version": "5.1.157",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A vue3 framework with ioc",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "zova-core": "^5.1.88",
49
- "zova-suite-a-zova": "^5.1.155"
49
+ "zova-suite-a-zova": "^5.1.156"
50
50
  },
51
51
  "devDependencies": {
52
52
  "clean-package": "^2.2.0",