cabloy 5.1.154 → 5.1.156

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 (61) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/scheduled_tasks.lock +1 -0
  3. package/.claude/skills/cabloy-contract-loop/SKILL.md +1 -1
  4. package/.claude/skills/cabloy-spec-generation/SKILL.md +1 -1
  5. package/.claude/skills/cabloy-workflow/SKILL.md +3 -3
  6. package/CHANGELOG.md +19 -0
  7. package/CLAUDE.md +1 -1
  8. package/README.md +18 -18
  9. package/package.json +1 -1
  10. package/repo-docs/.vitepress/public/CNAME +1 -1
  11. package/repo-docs/ai/edition-consistency-checklist.md +7 -7
  12. package/repo-docs/backend/quickstart.md +1 -1
  13. package/repo-docs/blogs/ai-react-nextjs-enterprise-architecture-cabloy/index.md +8 -8
  14. package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +13 -13
  15. package/repo-docs/blogs/vue-object-oriented-zova-beginner-mental-model/index.md +6 -6
  16. package/repo-docs/editions/cabloy-start.md +10 -12
  17. package/repo-docs/editions/choosing-between-basic-and-start.md +6 -7
  18. package/repo-docs/editions/detection.md +1 -1
  19. package/repo-docs/editions/overview.md +26 -14
  20. package/repo-docs/frontend/foundation.md +1 -1
  21. package/repo-docs/frontend/introduction.md +1 -1
  22. package/repo-docs/frontend/scripts.md +2 -2
  23. package/repo-docs/fullstack/deploy-cloudflare-docker.md +1 -1
  24. package/repo-docs/fullstack/edition-collaboration-differences.md +2 -2
  25. package/repo-docs/fullstack/introduction.md +7 -3
  26. package/repo-docs/fullstack/openapi-to-sdk.md +1 -1
  27. package/repo-docs/fullstack/quickstart.md +4 -4
  28. package/repo-docs/fullstack/vona-zova-integration.md +1 -1
  29. package/repo-docs/index.md +1 -1
  30. package/repo-docs/reference/glossary.md +8 -5
  31. package/repo-docs/reference/introduction.md +1 -1
  32. package/repo-docs/reference/repo-scripts.md +12 -4
  33. package/test-results/.last-run.json +2 -4
  34. package/vona/packages-vona/vona/package.json +1 -1
  35. package/vona/packages-vona/vona-shared/package.json +4 -4
  36. package/vona/pnpm-lock.yaml +8 -3
  37. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.redlock.ts +2 -1
  38. package/vona/src/suite/a-home/modules/home-user/src/service/roleAdapter.ts +115 -6
  39. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +163 -2
  40. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  41. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/index.ts +26 -0
  42. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/eventListener.roleMembershipChanged.ts +25 -0
  43. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  44. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  45. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  46. package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
  47. package/vona/src/suite-vendor/a-vona/modules/a-queue/src/lib/beanQueueBase.ts +1 -1
  48. package/vona/src/suite-vendor/a-vona/modules/a-queue/src/service/queue.ts +2 -2
  49. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  50. package/vona/src/suite-vendor/a-vona/modules/a-user/src/.metadata/index.ts +15 -0
  51. package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.role.ts +44 -3
  52. package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/event.roleMembershipChanged.ts +16 -0
  53. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +12 -1
  54. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  55. package/zova/pnpm-lock.yaml +1 -1
  56. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  57. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/resourceRouteMeta.ts +9 -0
  58. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/routes.ts +4 -10
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/lib/routes.test.ts +37 -0
  60. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  61. package/test-results/cabloy-basic-ATP-BASIC-SUM-58d8a-ry-states-and-Markdown-HTML/error-context.md +0 -195
@@ -26,7 +26,7 @@ The most important differences show up in:
26
26
  - frontend flavor names
27
27
  - frontend module composition
28
28
  - admin/web SSR site baselines
29
- - edition-specific private value-add content and project assets in Cabloy Start
29
+ - Start-specific value-add content and project assets
30
30
  - potentially different generated output paths or integration details
31
31
 
32
32
  ## Cabloy Basic
@@ -38,7 +38,7 @@ In the current public monorepo, Basic examples commonly align with:
38
38
 
39
39
  ## Cabloy Start
40
40
 
41
- In the sibling private repo, Start examples commonly align with:
41
+ In the sibling public Start repository, examples commonly align with:
42
42
 
43
43
  - Vuetify-oriented frontend assumptions
44
44
  - Start-specific Zova flavors such as `cabloyStartAdmin` and `cabloyStartWeb`
@@ -61,9 +61,13 @@ Use this path when the task depends on edition boundaries, UI assumptions, or cr
61
61
 
62
62
  ## Shared architecture
63
63
 
64
- - **Vona** provides the backend framework capabilities.
65
- - **Zova** provides the frontend framework capabilities.
66
- - The root repository coordinates the two through shared scripts, shared terminology, and a shared release story.
64
+ Cabloy coordinates its backend and frontend layers as one fullstack system:
65
+
66
+ - **Vona** provides the backend framework and runtime capabilities.
67
+ - **Zova** provides the frontend framework and application capabilities.
68
+ - Root scripts, shared terminology, and CLI-first workflows keep the layers aligned within each edition repository.
69
+
70
+ Cabloy Basic and Cabloy Start are related, complete edition baselines built on this shared architecture, not alternatives to the Vona and Zova layers. They intentionally compose UI, flavors, modules, SSR sites, and project assets differently. See [Editions Overview](/editions/overview) for the complete relationship and edition differences.
67
71
 
68
72
  This combination keeps backend and frontend development close enough for code sharing, workflow reuse, and AI vibe coding workflows.
69
73
 
@@ -204,7 +204,7 @@ If the endpoint still belongs to an existing business resource, prefer keeping `
204
204
 
205
205
  ## Cabloy Start workflow
206
206
 
207
- In the sibling private Start repo, the same collaboration idea applies, but the frontend flavor names differ.
207
+ In the sibling public Start repository, the same collaboration idea applies, but the frontend flavor names differ.
208
208
 
209
209
  Representative Start-specific flavors include:
210
210
 
@@ -73,9 +73,9 @@ npm run dev:zova:web # http://localhost:9000/
73
73
 
74
74
  ### Cabloy Start
75
75
 
76
- Cabloy Start is the private commercial edition. Instead of `npm create cabloy`, purchase access to the licensed private repository, clone that source directly, and run `npm run init`. Then use the frontend commands provided by that edition. Do not assume the Cabloy Basic flavor names apply to Cabloy Start.
76
+ Cabloy Start is the public MIT-licensed edition maintained in its own repository. Instead of `npm create cabloy`, clone its public repository directly and run `npm run init`. Then use the frontend commands provided by that edition. Do not assume the Cabloy Basic flavor names apply to Cabloy Start.
77
77
 
78
- For the full Start onboarding details, including the access and initialization flow, read [Cabloy Start](/editions/cabloy-start).
78
+ For the full Start onboarding and initialization flow, read [Cabloy Start](/editions/cabloy-start).
79
79
 
80
80
  If you are not sure which edition you are using or which one to choose, read:
81
81
 
@@ -123,7 +123,7 @@ Keep additional project-owned browser specs under other filenames in `repo-e2e/s
123
123
 
124
124
  ### Cabloy Start repository baseline
125
125
 
126
- Cabloy Start is checked out from the licensed private repository rather than created through `npm create cabloy`. Its E2E baseline is maintained in that repository:
126
+ Cabloy Start is cloned from its separate public repository rather than created through `npm create cabloy`. Its E2E baseline is maintained in that repository:
127
127
 
128
128
  ```text
129
129
  repo-e2e/config/
@@ -131,7 +131,7 @@ repo-e2e/scripts/
131
131
  repo-e2e/specs/
132
132
  ```
133
133
 
134
- The public Cabloy upgrade flow does not synchronize or repair those private Start E2E files, its root E2E scripts, or `@playwright/test`. Keep project-owned browser scenarios in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
134
+ The public Cabloy upgrade flow does not synchronize or repair those Start-owned E2E files, its root E2E scripts, or `@playwright/test`. Keep project-owned browser scenarios in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
135
135
 
136
136
  Prepare and run the managed Start baseline locally with:
137
137
 
@@ -51,7 +51,7 @@ cd zova && npm run build:rest:cabloyBasicWeb
51
51
 
52
52
  ## Cabloy Start
53
53
 
54
- Cabloy Start is the private commercial edition. It lives in a licensed sibling repository rather than a subdirectory here, but the same integration idea applies.
54
+ Cabloy Start is the public MIT-licensed edition. It lives in a separate sibling repository rather than a subdirectory here, but the same integration idea applies.
55
55
 
56
56
  Its root script surface uses Start-specific flavors such as:
57
57
 
@@ -35,7 +35,7 @@ features:
35
35
 
36
36
  Cabloy is a Node.js fullstack framework for AI vibe coding. It brings backend and frontend work into one system, with bidirectional type sync, CLI-first workflows, and source-grounded docs and skills for people and AI agents.
37
37
 
38
- Start with the [Contract Loop Playbook](/fullstack/contract-loop-playbook) to understand bidirectional type sync, then use the [Fullstack CLI](/fullstack/cli) and [AI Development](/ai/introduction) guides to follow the current repository. Vona, Zova, and suite-based modules provide the aligned architecture behind that workflow, while Cabloy Basic and Cabloy Start share the same fullstack core but diverge by edition.
38
+ Start with the [Contract Loop Playbook](/fullstack/contract-loop-playbook) to understand bidirectional type sync, then use the [Fullstack CLI](/fullstack/cli) and [AI Development](/ai/introduction) guides to follow the current repository. Cabloy coordinates Vona and Zova as its backend and frontend layers, while Cabloy Basic and Cabloy Start are related, complete edition baselines built on that shared architecture. See [Editions Overview](/editions/overview) for the relationship and deliberate edition differences.
39
39
 
40
40
  ## What you can do here
41
41
 
@@ -2,23 +2,25 @@
2
2
 
3
3
  ## Cabloy
4
4
 
5
- The fullstack framework and monorepo direction that combines backend and frontend workflows.
5
+ The coordinated Node.js fullstack system that combines backend and frontend workflows.
6
6
 
7
7
  ## Vona
8
8
 
9
- The backend framework in the Cabloy ecosystem.
9
+ Cabloy's backend framework and runtime layer.
10
10
 
11
11
  ## Zova
12
12
 
13
- The frontend framework in the Cabloy ecosystem.
13
+ Cabloy's frontend framework and application layer.
14
14
 
15
15
  ## Cabloy Basic
16
16
 
17
- The public framework/reference edition for the shared Cabloy architecture.
17
+ The public reference and community edition baseline for the shared Cabloy architecture, created with `npm create cabloy`.
18
18
 
19
19
  ## Cabloy Start
20
20
 
21
- The private commercial edition, provided as a licensed sibling repository with different UI, module, and project-baseline choices.
21
+ The public MIT-licensed business-system edition baseline for the shared Cabloy architecture, maintained in a sibling repository and initialized with `npm run init` after cloning.
22
+
23
+ Vona and Zova describe architectural layers; Cabloy Basic and Cabloy Start describe complete edition baselines built with those layers.
22
24
 
23
25
  ## Edition detection
24
26
 
@@ -35,4 +37,5 @@ Use the glossary together with:
35
37
  - [Fullstack Introduction](/fullstack/introduction)
36
38
  - [Backend (Vona)](/backend/introduction)
37
39
  - [Frontend (Zova)](/frontend/introduction)
40
+ - [Editions Overview](/editions/overview)
38
41
  - [Package Map](/reference/package-map)
@@ -42,6 +42,6 @@ Use this path when you need to find where packages, backend directories, fronten
42
42
 
43
43
  ## Edition impact
44
44
 
45
- Cabloy Start keeps the same high-level workflow pattern while using different frontend flavors such as `cabloyStartAdmin` and `cabloyStartWeb`, plus its own SSR site baselines and project assets in the licensed private repository.
45
+ Cabloy Start keeps the same high-level workflow pattern while using different frontend flavors such as `cabloyStartAdmin` and `cabloyStartWeb`, plus its own SSR site baselines and project assets in its separate public repository.
46
46
 
47
47
  When documenting or automating flavor-specific commands, always confirm the active repo first.
@@ -4,7 +4,15 @@ Use this page as the compact lookup surface for root scripts in Cabloy Basic and
4
4
 
5
5
  For the broader Reference landing page, see [Reference Introduction](/reference/introduction).
6
6
 
7
- Always start with the active repository's root `package.json`. Cabloy Basic is the public generated-project baseline; Cabloy Start is the licensed private repository and has its own root command surface.
7
+ Always start with the active repository's root `package.json`. Cabloy Basic is the public generated-project baseline; Cabloy Start is the public MIT-licensed edition in its own repository and has its own root command surface.
8
+
9
+ ## Generated workspace manifests
10
+
11
+ The tracked root `package.json` is the primary command surface. The tracked `vona/package.original.json` and `zova/package.original.json` files are durable bootstrap manifest inputs. Their sibling `vona/package.json` and `zova/package.json` files are ignored generated working dependency closures, not durable edit targets.
12
+
13
+ Root `npm run init` runs `scripts/init.ts`, which restores each generated workspace manifest from its `package.original.json` input before running dependency generation. During Vona initialization, it also seeds generated `.zova-rest` workspace dependencies before the first install and dependency-tool run. Direct edits to either ignored workspace manifest are overwritten by the next root initialization.
14
+
15
+ Run root `npm run init` deliberately after a fresh clone, recovery of generated workspace state, or a framework upgrade. Do not treat it as an automatic follow-up for narrow changes.
8
16
 
9
17
  ## Cabloy Basic entrypoints
10
18
 
@@ -112,9 +120,9 @@ repo-e2e/specs/a-commerce.spec.ts
112
120
 
113
121
  It also reconciles the two framework E2E scripts and the `@playwright/test` development dependency. Keep additional project browser specs under other filenames in `repo-e2e/specs`; the upgrader updates only the listed framework files. The current fresh baseline is required and is not repaired for unsupported legacy project layouts.
114
122
 
115
- ### Cabloy Start private repository
123
+ ### Cabloy Start repository
116
124
 
117
- The Start E2E baseline is maintained in the private repository:
125
+ The Start E2E baseline is maintained in the separate Start repository:
118
126
 
119
127
  ```text
120
128
  repo-e2e/config/
@@ -122,7 +130,7 @@ repo-e2e/scripts/
122
130
  repo-e2e/specs/
123
131
  ```
124
132
 
125
- The public-package upgrade flow does not source or reconcile the private Start baseline, its root E2E scripts, or `@playwright/test`. Keep project browser tests in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
133
+ The public-package upgrade flow does not source or reconcile the Start-owned baseline, its root E2E scripts, or `@playwright/test`. Keep project browser tests in the flat `repo-e2e/specs/` directory under distinct filenames, for example `repo-e2e/specs/my-project.spec.ts`.
126
134
 
127
135
  ## SSR browser checks
128
136
 
@@ -1,6 +1,4 @@
1
1
  {
2
- "status": "failed",
3
- "failedTests": [
4
- "0d2518050eca8cd1231a-03158dd238416d7c8c3e"
5
- ]
2
+ "status": "passed",
3
+ "failedTests": []
6
4
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona",
3
- "version": "5.1.89",
3
+ "version": "5.1.90",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-shared",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -8,15 +8,15 @@
8
8
  "vona",
9
9
  "vue3"
10
10
  ],
11
- "homepage": "https://github.com/cabloy/vona#readme",
11
+ "homepage": "https://github.com/vonajs/vona#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/cabloy/vona/issues"
13
+ "url": "https://github.com/vonajs/vona/issues"
14
14
  },
15
15
  "license": "MIT",
16
16
  "author": "zhennann",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/cabloy/vona.git"
19
+ "url": "git+https://github.com/vonajs/vona.git"
20
20
  },
21
21
  "files": [
22
22
  "dist"
@@ -615,8 +615,8 @@ importers:
615
615
  specifier: ^5.0.5
616
616
  version: 5.0.5
617
617
  zova-module-rest-resource:
618
- specifier: ^5.1.47
619
- version: 5.1.47
618
+ specifier: ^5.1.48
619
+ version: 5.1.49
620
620
 
621
621
  .zova-rest/cabloy-basic-web:
622
622
  dependencies:
@@ -1610,7 +1610,7 @@ importers:
1610
1610
  specifier: ^5.0.3
1611
1611
  version: link:modules/a-markdown
1612
1612
  vona-module-a-rbac:
1613
- specifier: ^5.0.1
1613
+ specifier: ^5.0.2
1614
1614
  version: link:modules/a-rbac
1615
1615
  vona-module-a-socket:
1616
1616
  specifier: workspace:^
@@ -9052,6 +9052,9 @@ packages:
9052
9052
  zova-module-rest-resource@5.1.47:
9053
9053
  resolution: {integrity: sha512-/LBZxfD/ULG+sQVuGnC+aqRe32O4qJesaKZF5IHNCuhuRwLzqvUIpijoyA890R7Ah5kHGxcnqaScW8DYkmf+RA==}
9054
9054
 
9055
+ zova-module-rest-resource@5.1.49:
9056
+ resolution: {integrity: sha512-tLf3BLov4GnV1QPivvLro6T2sNdriDZ8vZMsghuUN7dl+PRCG2pI0q9hBCrqe2faRSIcVUXowwnEbEynmaM7Zg==}
9057
+
9055
9058
  zova-suite-a-zova@5.1.152:
9056
9059
  resolution: {integrity: sha512-P/dcMb2fcMpnmDMlrgvpddWUbupnvohD1Jp60VYWoqI0JcWMlYTJvWQWrwszDqiMvMiOTH5mmQBTws0KzbsXpA==}
9057
9060
 
@@ -15541,6 +15544,8 @@ snapshots:
15541
15544
 
15542
15545
  zova-module-rest-resource@5.1.47: {}
15543
15546
 
15547
+ zova-module-rest-resource@5.1.49: {}
15548
+
15544
15549
  zova-suite-a-zova@5.1.152(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
15545
15550
  dependencies:
15546
15551
  zova-module-a-api: 5.1.22
@@ -8,7 +8,8 @@ export type TypeRedlockLockResource =
8
8
  | `homeUser.account.passwordSetCandidate.${string}`
9
9
  | `homeUser.account.passwordResetConsume.${string}`
10
10
  | `homeUser.account.activationMutation.${string}`
11
- | `homeUser.account.activationConsume.${string}`;
11
+ | `homeUser.account.activationConsume.${string}`
12
+ | `homeUser.roleMembership.${string}`;
12
13
  export type TypeRedlockLockIsolateResource = never;
13
14
 
14
15
  @Meta()
@@ -1,8 +1,9 @@
1
1
  import type { TableIdentity } from 'table-identity';
2
- import type { IRole, IRoleAdapter } from 'vona-module-a-user';
2
+ import type { IRole, IRoleAdapter, IRoleMembershipReplaceResult } from 'vona-module-a-user';
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
  @Service()
8
9
  export class ServiceRoleAdapter extends BeanBase implements IRoleAdapter {
@@ -19,11 +20,119 @@ export class ServiceRoleAdapter extends BeanBase implements IRoleAdapter {
19
20
  return user?.roles;
20
21
  }
21
22
 
22
- async addUserId(id: TableIdentity, userId: TableIdentity): Promise<TableIdentity> {
23
- let roleUser = await this.scope.model.roleUser.get({ userId, roleId: id });
24
- if (!roleUser) {
25
- roleUser = await this.scope.model.roleUser.insert({ userId, roleId: id });
23
+ async addUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
24
+ return await this.withUserMembershipLock(
25
+ userId,
26
+ async () => await this.addUserIdInTransaction(id, userId),
27
+ );
28
+ }
29
+
30
+ async removeUserId(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
31
+ return await this.withUserMembershipLock(
32
+ userId,
33
+ async () => await this.removeUserIdInTransaction(id, userId),
34
+ );
35
+ }
36
+
37
+ async replaceUserRoleIds(
38
+ userId: TableIdentity,
39
+ roleIds: TableIdentity[],
40
+ options?: { preserveRoleIds?: TableIdentity[] },
41
+ ): Promise<IRoleMembershipReplaceResult> {
42
+ const requestedRoleIds = this.uniqueIds(roleIds);
43
+ const preserveRoleIds = this.uniqueIds(options?.preserveRoleIds ?? []);
44
+ return await this.withUserMembershipLock(
45
+ userId,
46
+ async () =>
47
+ await this.replaceUserRoleIdsInTransaction(userId, requestedRoleIds, preserveRoleIds),
48
+ );
49
+ }
50
+
51
+ @Core.transaction()
52
+ private async addUserIdInTransaction(id: TableIdentity, userId: TableIdentity): Promise<boolean> {
53
+ const existing = await this.scope.model.roleUser.get({ userId, roleId: id });
54
+ if (existing) return false;
55
+ await this.scope.model.roleUser.insert({ userId, roleId: id });
56
+ return true;
57
+ }
58
+
59
+ @Core.transaction()
60
+ private async removeUserIdInTransaction(
61
+ id: TableIdentity,
62
+ userId: TableIdentity,
63
+ ): Promise<boolean> {
64
+ const memberships = await this.scope.model.roleUser.select({ where: { userId, roleId: id } });
65
+ if (!memberships.length) return false;
66
+ await this.scope.model.roleUser.deleteBulk(memberships.map(item => item.id));
67
+ return true;
68
+ }
69
+
70
+ @Core.transaction()
71
+ private async replaceUserRoleIdsInTransaction(
72
+ userId: TableIdentity,
73
+ requestedRoleIds: TableIdentity[],
74
+ preserveRoleIds: TableIdentity[],
75
+ ): Promise<IRoleMembershipReplaceResult> {
76
+ const memberships = await this.scope.model.roleUser.select({ where: { userId } });
77
+ const membershipsByRoleId = new Map<string, (typeof memberships)[number]>();
78
+ const duplicateMembershipIds: TableIdentity[] = [];
79
+ for (const membership of memberships) {
80
+ const key = String(membership.roleId);
81
+ if (membershipsByRoleId.has(key)) {
82
+ duplicateMembershipIds.push(membership.id);
83
+ } else {
84
+ membershipsByRoleId.set(key, membership);
85
+ }
86
+ }
87
+
88
+ const requestedRoleIdsByKey = this.idsByKey(requestedRoleIds);
89
+ const preserveRoleIdsByKey = this.idsByKey(preserveRoleIds);
90
+ const desiredRoleIdsByKey = new Map(requestedRoleIdsByKey);
91
+ for (const [key, roleId] of preserveRoleIdsByKey) {
92
+ if (membershipsByRoleId.has(key)) desiredRoleIdsByKey.set(key, roleId);
26
93
  }
27
- return roleUser.id;
94
+
95
+ const removedRoleIds: TableIdentity[] = [];
96
+ const obsoleteMembershipIds = [...duplicateMembershipIds];
97
+ for (const [key, membership] of membershipsByRoleId) {
98
+ if (!desiredRoleIdsByKey.has(key)) {
99
+ obsoleteMembershipIds.push(membership.id);
100
+ removedRoleIds.push(membership.roleId);
101
+ }
102
+ }
103
+ if (obsoleteMembershipIds.length) {
104
+ await this.scope.model.roleUser.deleteBulk(obsoleteMembershipIds);
105
+ }
106
+
107
+ const addedRoleIds: TableIdentity[] = [];
108
+ for (const [key, roleId] of requestedRoleIdsByKey) {
109
+ if (!membershipsByRoleId.has(key)) {
110
+ await this.scope.model.roleUser.insert({ userId, roleId });
111
+ addedRoleIds.push(roleId);
112
+ }
113
+ }
114
+ return { addedRoleIds, removedRoleIds };
115
+ }
116
+
117
+ private async withUserMembershipLock<RESULT>(
118
+ userId: TableIdentity,
119
+ operation: () => Promise<RESULT>,
120
+ ): Promise<RESULT> {
121
+ return await this.scope.redlock.lock(
122
+ `homeUser.roleMembership.${encodeURIComponent(String(userId))}`,
123
+ operation,
124
+ );
125
+ }
126
+
127
+ private uniqueIds(ids: TableIdentity[]): TableIdentity[] {
128
+ return [...this.idsByKey(ids).entries()]
129
+ .toSorted(([left], [right]) => left.localeCompare(right))
130
+ .map(([, id]) => id);
131
+ }
132
+
133
+ private idsByKey(ids: TableIdentity[]): Map<string, TableIdentity> {
134
+ const values = new Map<string, TableIdentity>();
135
+ for (const id of ids) values.set(String(id), id);
136
+ return values;
28
137
  }
29
138
  }
@@ -1,8 +1,8 @@
1
1
  import assert from 'node:assert';
2
- import { describe, it } from 'node:test';
2
+ import { describe, it, mock } from 'node:test';
3
3
  import { app } from 'vona-mock';
4
4
 
5
- describe('role.test.ts', () => {
5
+ describe('role.test.ts', { concurrency: false }, () => {
6
6
  it('action:roleSeedAndPassport', async () => {
7
7
  await app.bean.executor.mockCtx(async () => {
8
8
  const scope = app.scope('home-user');
@@ -41,4 +41,165 @@ describe('role.test.ts', () => {
41
41
  assert.equal(passportSystemAdmin?.builtin, true);
42
42
  });
43
43
  });
44
+
45
+ it('bean:role emits only actual membership changes', async () => {
46
+ let userId: string | undefined;
47
+ let roleId: string | undefined;
48
+ const events: unknown[] = [];
49
+ const event = app.scope('a-user').event.roleMembershipChanged;
50
+ const originalEmit = event.emit.bind(event);
51
+ const emitMock = mock.method(event, 'emit', async data => {
52
+ events.push(data);
53
+ return await originalEmit(data);
54
+ });
55
+ try {
56
+ await app.bean.executor.mockCtx(async () => {
57
+ const role = await app.scope('home-user').model.role.insert({
58
+ name: `home-user-role-${crypto.randomUUID()}`,
59
+ title: 'Home User Role',
60
+ siteIds: ['admin'],
61
+ builtin: false,
62
+ });
63
+ roleId = String(role.id);
64
+ const user = await app.bean.user.register({
65
+ name: `home-user-role-member-${crypto.randomUUID()}`,
66
+ });
67
+ userId = String(user.id);
68
+
69
+ assert.equal(await app.bean.role.addUserId(role.id, user.id), true);
70
+ assert.equal(await app.bean.role.addUserId(role.id, user.id), false);
71
+
72
+ const memberships = await app.scope('home-user').model.roleUser.select({
73
+ where: { userId: user.id, roleId: role.id },
74
+ });
75
+ assert.equal(memberships.length, 1);
76
+ assert.deepEqual(events, [{ userIds: [user.id], roleIds: [role.id] }]);
77
+
78
+ assert.equal(await app.bean.role.removeUserId(role.id, user.id), true);
79
+ assert.equal(await app.bean.role.removeUserId(role.id, user.id), false);
80
+ assert.deepEqual(events, [
81
+ { userIds: [user.id], roleIds: [role.id] },
82
+ { userIds: [user.id], roleIds: [role.id] },
83
+ ]);
84
+ });
85
+ } finally {
86
+ try {
87
+ await app.bean.executor.mockCtx(async () => {
88
+ const homeUser = app.scope('home-user');
89
+ if (userId) {
90
+ await homeUser.model.roleUser.delete({ userId });
91
+ await app.bean.user.removeById(userId);
92
+ }
93
+ if (roleId) await homeUser.model.role.deleteById(roleId);
94
+ });
95
+ } finally {
96
+ emitMock.mock.restore();
97
+ }
98
+ }
99
+ });
100
+
101
+ it('bean:role rolls back membership and policy invalidation together', async () => {
102
+ let userId: string | undefined;
103
+ let roleId: string | undefined;
104
+ try {
105
+ await app.bean.executor.mockCtx(async () => {
106
+ const homeUser = app.scope('home-user');
107
+ const role = await homeUser.model.role.insert({
108
+ name: `home-user-rollback-${crypto.randomUUID()}`,
109
+ title: 'Home User Rollback Role',
110
+ siteIds: ['admin'],
111
+ builtin: false,
112
+ });
113
+ roleId = String(role.id);
114
+ const user = await app.bean.user.register({
115
+ name: `home-user-role-rollback-${crypto.randomUUID()}`,
116
+ });
117
+ userId = String(user.id);
118
+
119
+ await assert.rejects(async () => {
120
+ await app.bean.database.current.transaction.begin(async () => {
121
+ await app.bean.role.addUserId(role.id, user.id);
122
+ throw new Error('rollback role membership');
123
+ });
124
+ }, /rollback role membership/);
125
+
126
+ assert.equal(
127
+ await homeUser.model.roleUser.get({ userId: user.id, roleId: role.id }),
128
+ undefined,
129
+ );
130
+ });
131
+ } finally {
132
+ await app.bean.executor.mockCtx(async () => {
133
+ const homeUser = app.scope('home-user');
134
+ if (userId) {
135
+ await homeUser.model.roleUser.delete({ userId });
136
+ await app.bean.user.removeById(userId);
137
+ }
138
+ if (roleId) await homeUser.model.role.deleteById(roleId);
139
+ });
140
+ }
141
+ });
142
+
143
+ it('bean:role replaces a membership set with one change event', async () => {
144
+ let userId: string | undefined;
145
+ const roleIds: string[] = [];
146
+ const events: unknown[] = [];
147
+ const event = app.scope('a-user').event.roleMembershipChanged;
148
+ const originalEmit = event.emit.bind(event);
149
+ const emitMock = mock.method(event, 'emit', async data => {
150
+ events.push(data);
151
+ return await originalEmit(data);
152
+ });
153
+ try {
154
+ await app.bean.executor.mockCtx(async () => {
155
+ const homeUser = app.scope('home-user');
156
+ const roles = await Promise.all(
157
+ ['a', 'b', 'c'].map(async suffix => {
158
+ const role = await homeUser.model.role.insert({
159
+ name: `home-user-replace-${suffix}-${crypto.randomUUID()}`,
160
+ title: `Home User Role ${suffix}`,
161
+ siteIds: ['admin'],
162
+ builtin: false,
163
+ });
164
+ roleIds.push(String(role.id));
165
+ return role;
166
+ }),
167
+ );
168
+ const user = await app.bean.user.register({
169
+ name: `home-user-role-replace-${crypto.randomUUID()}`,
170
+ });
171
+ userId = String(user.id);
172
+ await app.bean.role.addUserId(roles[0].id, user.id);
173
+ await app.bean.role.addUserId(roles[1].id, user.id);
174
+ events.length = 0;
175
+
176
+ const result = await app.bean.role.replaceUserRoleIds(user.id, [roles[1].id, roles[2].id]);
177
+ assert.deepEqual(result, { addedRoleIds: [roles[2].id], removedRoleIds: [roles[0].id] });
178
+ assert.deepEqual(events, [{ userIds: [user.id], roleIds: [roles[0].id, roles[2].id] }]);
179
+
180
+ const memberships = await homeUser.model.roleUser.select({ where: { userId: user.id } });
181
+ assert.deepEqual(
182
+ memberships.map(item => String(item.roleId)).toSorted(),
183
+ [String(roles[1].id), String(roles[2].id)].toSorted(),
184
+ );
185
+ await app.bean.role.replaceUserRoleIds(user.id, [roles[1].id, roles[2].id]);
186
+ assert.equal(events.length, 1);
187
+ });
188
+ } finally {
189
+ try {
190
+ await app.bean.executor.mockCtx(async () => {
191
+ const homeUser = app.scope('home-user');
192
+ if (userId) {
193
+ await homeUser.model.roleUser.delete({ userId });
194
+ await app.bean.user.removeById(userId);
195
+ }
196
+ for (const roleId of roleIds.reverse()) {
197
+ await homeUser.model.role.deleteById(roleId);
198
+ }
199
+ });
200
+ } finally {
201
+ emitMock.mock.restore();
202
+ }
203
+ }
204
+ });
44
205
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-rbac",
3
- "version": "5.0.2",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -109,6 +109,32 @@ declare module 'vona-module-a-event' {
109
109
  }
110
110
  }
111
111
  /** event: end */
112
+ /** eventListener: begin */
113
+ export * from '../bean/eventListener.roleMembershipChanged.ts';
114
+
115
+ import { type IDecoratorEventListenerOptions } from 'vona-module-a-event';
116
+ declare module 'vona-module-a-event' {
117
+
118
+ export interface IEventListenerRecord {
119
+ 'a-rbac:roleMembershipChanged': IDecoratorEventListenerOptions;
120
+ }
121
+
122
+
123
+ }
124
+ declare module 'vona-module-a-rbac' {
125
+
126
+ export interface EventListenerRoleMembershipChanged {
127
+ /** @internal */
128
+ get scope(): ScopeModuleARbac;
129
+ }
130
+
131
+ export interface EventListenerRoleMembershipChanged {
132
+ get $beanFullName(): 'a-rbac.eventListener.roleMembershipChanged';
133
+ get $onionName(): 'a-rbac:roleMembershipChanged';
134
+ get $onionOptions(): IDecoratorEventListenerOptions;
135
+ }
136
+ }
137
+ /** eventListener: end */
112
138
  /** config: begin */
113
139
  export * from '../config/config.ts';
114
140
  import type { config } from '../config/config.ts';
@@ -0,0 +1,25 @@
1
+ import type { IEventExecute, NextEvent } from 'vona-module-a-event';
2
+ import type {
3
+ TypeEventRoleMembershipChangedData,
4
+ TypeEventRoleMembershipChangedResult,
5
+ } from 'vona-module-a-user';
6
+
7
+ import { BeanBase } from 'vona';
8
+ import { EventListener } from 'vona-module-a-event';
9
+
10
+ type TypeEventData = TypeEventRoleMembershipChangedData;
11
+ type TypeEventResult = TypeEventRoleMembershipChangedResult;
12
+
13
+ @EventListener({ match: 'a-user:roleMembershipChanged' })
14
+ export class EventListenerRoleMembershipChanged
15
+ extends BeanBase
16
+ implements IEventExecute<TypeEventData, TypeEventResult>
17
+ {
18
+ async execute(
19
+ data: TypeEventData,
20
+ next: NextEvent<TypeEventData, TypeEventResult>,
21
+ ): Promise<TypeEventResult> {
22
+ await this.scope.event.policyInvalidated.emit({ kind: 'role' });
23
+ return await next(data);
24
+ }
25
+ }