@ahoo-wang/wow-view-store 9.2.0-rc.0

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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [2024] [ahoo wang <ahoowang@qq.com>]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # `@ahoo-wang/wow-view-store`
2
+
3
+ [简体中文](./README.zh-CN.md)
4
+
5
+ The view engine's `ViewStore` on a Wow server: the saved views and preferences
6
+ of [`@ahoo-wang/wow-view-engine`](../wow-view-engine/README.md), kept by the
7
+ [view store](../../view-store/README.md) — two Wow aggregates, served by the
8
+ standalone `wow-view-store-server` or by any Wow service that embeds
9
+ `wow-view-store-starter`.
10
+
11
+ Released with Wow 9.2.0, from the same tag and with the same version, together
12
+ with the view engine and the view store's server modules.
13
+
14
+ > **Compatibility.** From 9.2.0 on, a patch release never breaks the public
15
+ > surface (the exports of the entry and the error codes); a minor release may,
16
+ > and its release notes list every break with the steps to follow. Keep the Wow
17
+ > packages on one minor, as [version ranges](https://wow.ahoo.me/guide/typescript/compatibility#version-ranges)
18
+ > explains.
19
+
20
+ ## Use
21
+
22
+ Peer dependencies: `@ahoo-wang/fetcher`, `@ahoo-wang/wow-client` and
23
+ `@ahoo-wang/wow-view-engine`, and the peers those declare, which a host installs
24
+ with them: `@ahoo-wang/fetcher-decorator` and `@ahoo-wang/fetcher-eventstream`
25
+ (wow-client's), and the view engine's optional `react`, `react-dom`,
26
+ `react-router` and `mingo` where its own entries need them. Node `>=22.12.0` or
27
+ a current browser; TypeScript 6 or later.
28
+
29
+ `WowViewStore` takes a fetcher and, optionally, the permissions that drive the
30
+ buttons. It takes no tenant, user or application: the fetcher's interceptors
31
+ carry who is asking, as they do for every other Wow request of the host.
32
+
33
+ <!-- typecheck-context
34
+ import type { QueryApi } from '@ahoo-wang/wow-client';
35
+ import type { ViewDefinition, ViewPermissions } from '@ahoo-wang/wow-view-engine';
36
+ declare const orders: ViewDefinition;
37
+ declare const ordersSource: Pick<QueryApi<any>, 'paged' | 'cursor' | 'aggregate'>;
38
+ declare const tokenStorage: TokenStorage;
39
+ declare const permissionsFromRoles: (definitionId: string) => ViewPermissions;
40
+ -->
41
+
42
+ ```ts
43
+ import { Fetcher } from '@ahoo-wang/fetcher';
44
+ import {
45
+ ResourceAttributionRequestInterceptor,
46
+ type TokenStorage,
47
+ } from '@ahoo-wang/fetcher-cosec';
48
+ import { ViewEngine } from '@ahoo-wang/wow-view-engine';
49
+ import { WowViewStore } from '@ahoo-wang/wow-view-store';
50
+
51
+ // The gateway in front of the view store. In a host, the fetcher already
52
+ // carries CoSec's interceptors: the authorization, `CoSec-App-Id`
53
+ // (CoSecRequestInterceptor) and the path's {tenantId} and {ownerId}
54
+ // (ResourceAttributionRequestInterceptor, from the token).
55
+ const fetcher = new Fetcher({ baseURL: 'https://api.example.com' });
56
+ fetcher.interceptors.request.use(
57
+ new ResourceAttributionRequestInterceptor({ tokenStorage }),
58
+ );
59
+
60
+ const engine = new ViewEngine({
61
+ store: new WowViewStore({ fetcher, permissions: permissionsFromRoles }),
62
+ resources: [{ definition: orders, source: ordersSource }],
63
+ });
64
+ ```
65
+
66
+ `permissions` decides which buttons are enabled, never what a write may do: the
67
+ server trusts its paths, and the CoSec gateway decides who may use which. Give
68
+ `createShared` and `changeAudience` by the role that may write
69
+ `owner/(shared)` — creating a shared view, claiming one and sharing a personal
70
+ one all need it. Left out, everything is allowed — but the engine's
71
+ `editSystem` (publishing, editing and unpublishing stored system views) is
72
+ off unless the host's answer says `true`: give it by the role the gateway
73
+ admits to `tenant/(platform)/owner/(system)` (`view-store/README.md`, 「System views」).
74
+
75
+ ### A host nobody signs in to
76
+
77
+ Without a token, nothing fills the path's tenant and owner. Such a host adds an
78
+ interceptor of its own that fills defaults where the request gives none, and
79
+ names its application; with the owner `(shared)` (`SHARED_OWNER_ID`) it has
80
+ shared views and shared preferences only, so its permissions leave
81
+ `createPersonal` off.
82
+
83
+ <!-- typecheck-context
84
+ import { Fetcher } from '@ahoo-wang/fetcher';
85
+ declare const fetcher: Fetcher;
86
+ -->
87
+
88
+ ```ts
89
+ import type { FetchExchange, RequestInterceptor } from '@ahoo-wang/fetcher';
90
+ import { SHARED_OWNER_ID } from '@ahoo-wang/wow-view-store';
91
+
92
+ class ConsoleDefaults implements RequestInterceptor {
93
+ readonly name = 'ConsoleDefaults';
94
+ readonly order = 0;
95
+
96
+ intercept(exchange: FetchExchange): void {
97
+ const path = exchange.ensureRequestUrlParams().path;
98
+ path.tenantId ??= '(0)';
99
+ path.ownerId ??= SHARED_OWNER_ID;
100
+ exchange.ensureRequestHeaders()['CoSec-App-Id'] = 'console';
101
+ }
102
+ }
103
+
104
+ fetcher.interceptors.request.use(new ConsoleDefaults());
105
+ ```
106
+
107
+ ## How it maps the port
108
+
109
+ Every route is under `/view-store/tenant/{tenantId}/owner/{ownerId}`, and **the
110
+ owner segment is the audience**: a personal view lives on the caller's own path,
111
+ a shared one on `owner/(shared)`.
112
+
113
+ | Port | Request |
114
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `list` | The snapshot list on the caller's path and on `(shared)`, projected to the summary (`state.config.kind`, not the config), and `GET (shared)/system-views?definitionId=`, sent together |
116
+ | `get` | The snapshot of one id where the view was last seen; an unknown id is looked up on the caller's path, then `(shared)`, then the system views |
117
+ | `create` | `POST …/view` on the path of its `scope` (`system`: `tenant/(platform)/owner/(system)`); the server generates the id |
118
+ | `save`, `rename`, `delete` | `PUT …/view/{id}/save`, `/rename`, `DELETE …/view/{id}` on the view's path |
119
+ | `changeAudience('shared')` | `PUT …/view/{id}/share` on the view's path |
120
+ | `changeAudience('personal')` | `PUT …/view/{id}/claim` on the caller's own path |
121
+ | `getPreferences`, `setPreferences` | `GET`, `PUT …/definitions/{definitionId}/preferences` on the caller's path |
122
+
123
+ A list answers in the port's order — the system views, then the shared views,
124
+ then the caller's, each audience oldest first (the server sorts it by
125
+ `firstEventTime`) — and reads at most 1,000 views of each audience, the server's
126
+ query budget: the oldest 1,000. A view past the cut is still read by its id, and
127
+ the workbench asks for one an address names before it sets it aside.
128
+
129
+ **Writes** send the port's `requestId` as `Command-Request-Id`, the `revision`
130
+ as `Command-Aggregate-Version` (`'0'` for preferences never written), and wait
131
+ for the snapshot. The answer is the view read back at the version the write
132
+ left. `share` and `delete` send `{}`, `claim` no body.
133
+
134
+ **A retry answers the first outcome.** A write the server refuses as a stale
135
+ version or a repeated request id is looked up by its request id first
136
+ (`GET …/view/requests/{requestId}`, on the path it was sent to and then the
137
+ other one): if the first attempt landed, its outcome is the answer. Only then is
138
+ a stale version `CONFLICT`, carrying the view as it is now — or `NOT_FOUND`,
139
+ when the view is gone. A lookup the server fails is no answer: on the path the
140
+ write went to, the outcome is unknown (`UNAVAILABLE`, and a retry is safe); on the
141
+ other path — `(shared)` refuses a caller without the shared role — the write is
142
+ taken as not replayed, and the refusal stands. A write that landed and was moved
143
+ on by another writer before the read back answers the view as the replay route
144
+ gives it, or, if that route cannot be asked, as it was read. Preferences have no such route: the store answers a retry
145
+ with what its own first attempt answered, and a retry whose first answer was
146
+ lost with what is stored, when that is what it wrote.
147
+
148
+ **Creating is not idempotent on the server**, which generates the id. The store
149
+ remembers the request ids of its own creates (the last 256), and a retry of one
150
+ first asks the replay route on the path of its `scope`: if the first attempt
151
+ landed, that view is the answer, and nothing is posted again. A retry from
152
+ another store instance — another tab, a reload — makes a second view.
153
+
154
+ **A view that moved.** When another tab shared a view this store remembers as
155
+ personal (or the other way round), the write is refused on the old path; the
156
+ store looks the view up again and sends the write once more to where it is.
157
+
158
+ **System views.** The server's system views (`GET (shared)/system-views`) are
159
+ configured ones, read-only, and stored ones (`source: 'stored'`), which
160
+ `WowViewStore` lists and reads with `stored: true` on the summary and the
161
+ instance. A stored one is written on `tenant/(platform)/owner/(system)`
162
+ (`SYSTEM_TENANT_ID`, `SYSTEM_OWNER_ID`) whatever the caller's tenant: they are
163
+ global. `create({ scope: 'system', … })` publishes one (a copy: the source
164
+ view stays), `save`, `rename` and `delete` edit and unpublish it;
165
+ `changeAudience` and any write to a configured or code system view are
166
+ `FORBIDDEN` before anything is sent.
167
+
168
+ A system view's `revision` is a hash of its content, as the engine needs for
169
+ its dirty baseline, while a write expects the aggregate version: the store
170
+ remembers the `version` each read of a stored view gave beside its revision,
171
+ sends that version, and when the engine holds a revision it has no version
172
+ for, reads the view again first — a revision other than the view's is
173
+ `CONFLICT` with the view as it is, and nothing is sent. The answer of a write
174
+ is read back from the system-views route at the version it left. A retry the
175
+ server refuses as a repeated request is found by the replay route on the
176
+ system path, but that route answers a snapshot, whose content hash only the
177
+ server computes: the store answers it with the system view **as it is now**,
178
+ which is the retried write's outcome unless another admin wrote in between.
179
+ A retry whose first attempt moved the view on (so the revision it holds is no
180
+ longer the view's) asks the replay route before it is refused as `CONFLICT`,
181
+ and a retried create of a system view answers the view from the system
182
+ views, flagged and at its hash.
183
+
184
+ ## Errors
185
+
186
+ Every rejection is the engine's `ViewStoreError`, read by Wow's error code — the
187
+ HTTP status only when an answer carries no code the store knows:
188
+
189
+ | Server | Port |
190
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
191
+ | `CommandExpectVersionConflict`, `EventVersionConflict`, `SourcingVersionConflict`; without a known code, 409 and 412 | `CONFLICT` |
192
+ | `NotFound` (also a view of another application), `IllegalAccessDeletedAggregate`; without a known code, 404 and 410 | `NOT_FOUND` |
193
+ | `IllegalAccessOwnerAggregate`, `IllegalAccessSpaceAggregate`, `IllegalAccessQueryScope`, `SystemViewReadOnly`, `ViewEventStreamClosed`; without a known code, 401 and 403 | `FORBIDDEN` |
194
+ | `ViewInvalid`, `ViewAppRequired`, `ViewScopeRequired`, `BadRequest`, `CommandValidation`, `IllegalArgument`, `DuplicateAggregateId`, `QuerySchemaValidation`; without a known code, 400 and 422; a path variable the fetcher's interceptors never filled (nothing is sent) | `INVALID` |
195
+ | `IllegalState`, `RequestTimeout`, `TooManyRequests`, `InternalServerError`, `QuerySchemaUnavailable`, `QuerySchemaConflict`; without a known code, any other status; no answer at all | `UNAVAILABLE` |
196
+ | `404` from a server with no view store at all (one released before it): a list, or a read whose every place is `404` while the server's system views are too | `UNSUPPORTED` |
197
+
198
+ Every `ViewStoreError` keeps what it was read from: the request's failure as
199
+ `cause`, the server's `errorCode` as `detail.code` (so a host tells
200
+ `ViewAppRequired` from `ViewInvalid`, both `INVALID`), and on an `UNAVAILABLE`
201
+ the server answered — a 5xx, a timeout it reported, a page that is not JSON —
202
+ `reachable: true`, which the engine says as 「服务端暂时无法处理」 rather than
203
+ 「无法连接服务端」.
204
+
205
+ A repeated request id (`DuplicateRequestId`) is not an error of its own: the
206
+ store looks the first attempt up (above). A path variable left unfilled is the
207
+ host's set-up — its interceptors do not fill `{tenantId}`, or `{ownerId}` on a
208
+ personal path — so it is `INVALID`, as the server's own `ViewScopeRequired` is:
209
+ the request is wrong as built, and a retry would send it the same.
210
+
211
+ A claim the server refuses because shared dashboards show the view carries those
212
+ boards' **titles** in the error's `boards`, as stored, and the view engine says
213
+ the refusal in its own words around them: a title written as a key stays a key,
214
+ said where the refusal is shown. The `message` keeps the server's words, for
215
+ logs.
216
+
217
+ `WowViewStoreErrorCodes` names the view store's own codes.
218
+
219
+ ## Testing
220
+
221
+ - `pnpm --filter @ahoo-wang/wow-view-store test` — unit tests against a fake
222
+ server, the public surface and the API report.
223
+ - The port's conformance suite runs over `WowViewStore` against a view store
224
+ server in `typescript/integration-test` (`test/view-store/`), with the tenant
225
+ and application isolation.
226
+
227
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,174 @@
1
+ # `@ahoo-wang/wow-view-store`
2
+
3
+ [English](./README.md)
4
+
5
+ 视图引擎的 `ViewStore` 落在 Wow 服务端上:[`@ahoo-wang/wow-view-engine`](../wow-view-engine/README.zh-CN.md)
6
+ 保存的视图与偏好,由[视图存储](../../view-store/README.md)保管——两个 Wow 聚合,由独立的
7
+ `wow-view-store-server` 提供,或由任何引入了 `wow-view-store-starter` 的 Wow 服务提供。
8
+
9
+ 随 Wow 9.2.0 发布,与 Wow 同一个 tag、同一个版本号,与视图引擎和视图存储的服务端模块一起发布。
10
+
11
+ > **兼容性。** 从 9.2.0 起,补丁版本不破坏公开面(入口的导出与错误码);次版本可以破坏,它的发布说明
12
+ > 逐条列出每个破坏与迁移步骤。Wow 包请停在同一个次版本上,见[版本范围](https://wow.ahoo.me/zh/guide/typescript/compatibility#版本范围)。
13
+
14
+ ## 使用
15
+
16
+ 对等依赖:`@ahoo-wang/fetcher`、`@ahoo-wang/wow-client` 与 `@ahoo-wang/wow-view-engine`,以及它们自己声明、
17
+ 宿主要一并安装的对等依赖:wow-client 的 `@ahoo-wang/fetcher-decorator` 与 `@ahoo-wang/fetcher-eventstream`,
18
+ 视图引擎的可选依赖 `react`、`react-dom`、`react-router` 与 `mingo`(用到它对应的入口时)。Node `>=22.12.0`
19
+ 或当前的浏览器;TypeScript 6 及以上。
20
+
21
+ `WowViewStore` 接收一个 fetcher,以及可选的、决定按钮是否可用的权限。它不收租户、用户或应用:
22
+ 是谁在请求由 fetcher 的拦截器带上,与宿主的其他 Wow 请求一样。
23
+
24
+ <!-- typecheck-context
25
+ import type { QueryApi } from '@ahoo-wang/wow-client';
26
+ import type { ViewDefinition, ViewPermissions } from '@ahoo-wang/wow-view-engine';
27
+ declare const orders: ViewDefinition;
28
+ declare const ordersSource: Pick<QueryApi<any>, 'paged' | 'cursor' | 'aggregate'>;
29
+ declare const tokenStorage: TokenStorage;
30
+ declare const permissionsFromRoles: (definitionId: string) => ViewPermissions;
31
+ -->
32
+
33
+ ```ts
34
+ import { Fetcher } from '@ahoo-wang/fetcher';
35
+ import {
36
+ ResourceAttributionRequestInterceptor,
37
+ type TokenStorage,
38
+ } from '@ahoo-wang/fetcher-cosec';
39
+ import { ViewEngine } from '@ahoo-wang/wow-view-engine';
40
+ import { WowViewStore } from '@ahoo-wang/wow-view-store';
41
+
42
+ // 视图存储前面的网关。宿主里这个 fetcher 已经带着 CoSec 的拦截器:认证、
43
+ // `CoSec-App-Id`(CoSecRequestInterceptor),以及路径里的 {tenantId} 与 {ownerId}
44
+ // (ResourceAttributionRequestInterceptor,取自令牌)。
45
+ const fetcher = new Fetcher({ baseURL: 'https://api.example.com' });
46
+ fetcher.interceptors.request.use(
47
+ new ResourceAttributionRequestInterceptor({ tokenStorage }),
48
+ );
49
+
50
+ const engine = new ViewEngine({
51
+ store: new WowViewStore({ fetcher, permissions: permissionsFromRoles }),
52
+ resources: [{ definition: orders, source: ordersSource }],
53
+ });
54
+ ```
55
+
56
+ `permissions` 只决定哪些按钮可用,从不决定一次写入能不能做:服务端信任路径,谁能用哪条路径由 CoSec
57
+ 网关决定。`createShared` 与 `changeAudience` 按能写 `owner/(shared)` 的角色给——新建共享视图、收为个人、设为共享都需要它。不给则全部允许——
58
+ 但引擎的 `editSystem`(发布、编辑、取消发布存储的系统视图)只在宿主明确答 `true` 时打开:按网关放行
59
+ `tenant/(platform)/owner/(system)` 的角色给(`view-store/README.md`「System views」)。
60
+
61
+ ### 不登录的宿主
62
+
63
+ 没有令牌,就没有谁去填路径里的租户与所有者。这样的宿主加一个自己的拦截器,在请求没给时填上缺省值,并说明自己
64
+ 是哪个应用;所有者填 `(shared)`(`SHARED_OWNER_ID`)时,它只有共享视图与共享偏好,所以它的权限把
65
+ `createPersonal` 关掉。
66
+
67
+ <!-- typecheck-context
68
+ import { Fetcher } from '@ahoo-wang/fetcher';
69
+ declare const fetcher: Fetcher;
70
+ -->
71
+
72
+ ```ts
73
+ import type { FetchExchange, RequestInterceptor } from '@ahoo-wang/fetcher';
74
+ import { SHARED_OWNER_ID } from '@ahoo-wang/wow-view-store';
75
+
76
+ class ConsoleDefaults implements RequestInterceptor {
77
+ readonly name = 'ConsoleDefaults';
78
+ readonly order = 0;
79
+
80
+ intercept(exchange: FetchExchange): void {
81
+ const path = exchange.ensureRequestUrlParams().path;
82
+ path.tenantId ??= '(0)';
83
+ path.ownerId ??= SHARED_OWNER_ID;
84
+ exchange.ensureRequestHeaders()['CoSec-App-Id'] = 'console';
85
+ }
86
+ }
87
+
88
+ fetcher.interceptors.request.use(new ConsoleDefaults());
89
+ ```
90
+
91
+ ## 端口怎样落到请求上
92
+
93
+ 所有路由都在 `/view-store/tenant/{tenantId}/owner/{ownerId}` 之下,**所有者段就是受众**:个人视图在调用者
94
+ 自己的路径上,共享视图在 `owner/(shared)` 上。
95
+
96
+ | 端口 | 请求 |
97
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `list` | 调用者路径与 `(shared)` 上的快照列表,只投影摘要的字段(`state.config.kind`,不含配置),以及 `GET (shared)/system-views?definitionId=`,三个请求一起发 |
99
+ | `get` | 在上次见到这个视图的地方按 id 读快照;没见过的 id 依次在调用者路径、`(shared)`、系统视图上找 |
100
+ | `create` | 在其 `scope` 对应的路径上 `POST …/view`(`system`:`tenant/(platform)/owner/(system)`);id 由服务端生成 |
101
+ | `save`、`rename`、`delete` | 在视图所在路径上 `PUT …/view/{id}/save`、`/rename`,`DELETE …/view/{id}` |
102
+ | `changeAudience('shared')` | 在视图所在路径上 `PUT …/view/{id}/share` |
103
+ | `changeAudience('personal')` | 在调用者自己的路径上 `PUT …/view/{id}/claim` |
104
+ | `getPreferences`、`setPreferences` | 调用者路径上的 `GET`、`PUT …/definitions/{definitionId}/preferences` |
105
+
106
+ 列表按端口规定的顺序作答——系统视图、共享视图、调用者的个人视图,每种受众按创建先后(服务端按 `firstEventTime`
107
+ 排序)——每种受众最多读 1000 个,即服务端的查询上限:最早的 1000 个。截断之外的视图仍可按 id 读到,地址里点名的
108
+ 视图工作台会先按 id 问一次,再决定是不是别的定义的。
109
+
110
+ **写入**把端口的 `requestId` 作为 `Command-Request-Id`、`revision` 作为 `Command-Aggregate-Version`
111
+ (从没写过的偏好是 `'0'`)发送,并等到快照落地。作答的是按这次写入留下的版本读回的视图。`share` 与 `delete`
112
+ 发送 `{}`,`claim` 不带请求体。
113
+
114
+ **重试答第一次的结果。** 服务端以过期版本或重复的请求 id 拒绝一次写入时,先按请求 id 查一次
115
+ (`GET …/view/requests/{requestId}`,先在这次发往的路径上,再在另一条上):第一次已经落地,就以它的结果作答。
116
+ 之后过期版本才是 `CONFLICT`,带上视图现在的样子——视图已经不在了则是 `NOT_FOUND`。服务端答不上来的查询不算回答:
117
+ 在这次发往的路径上,结局未知(`UNAVAILABLE`,重试安全);在另一条路径上——没有共享角色的调用者在 `(shared)`
118
+ 会被拒——就当没有重放,原来的拒绝照旧。已经落地、读回前又被别人推过的写入,以重放路由给的那一版作答;
119
+ 那条路由问不了时,以读回的作答。偏好没有这样的路由:
120
+ store 以自己第一次得到的结果回答重试;第一次的回答丢了的重试,在存着的正是它写的内容时,以存着的作答。
121
+
122
+ **创建在服务端不是幂等的**,id 由服务端生成。store 记着自己发出的创建的请求 id(最近 256 个),
123
+ 重试其中一个时先在其 `scope` 的路径上问重放路由:第一次已经落地,就以那个视图作答,不再发一次。
124
+ 从另一个 store 实例发出的重试——另一个标签页、刷新之后——会再建一个视图。
125
+
126
+ **挪了地方的视图。** 另一个标签页把这个 store 记作个人的视图设为了共享(或反过来),写入在旧路径上被拒;
127
+ store 重新找到视图,把写入再发一次到它现在所在的地方。
128
+
129
+ **系统视图。** 服务端的系统视图(`GET (shared)/system-views`)有配置的,只读;也有存储的(`source: 'stored'`),
130
+ `WowViewStore` 列出、读取它们时在摘要与实例上带 `stored: true`。存储的系统视图是全局的,不管调用者在哪个租户,
131
+ 都写在 `tenant/(platform)/owner/(system)`(`SYSTEM_TENANT_ID`、`SYSTEM_OWNER_ID`)上。`create({ scope: 'system', … })`
132
+ 发布一个(是复制,源视图不变),`save`、`rename`、`delete` 编辑与取消发布;`changeAudience`,以及对配置的或
133
+ 代码声明的系统视图的任何写入,都在发出请求之前就是 `FORBIDDEN`。
134
+
135
+ 系统视图的 `revision` 是内容的散列,引擎的「已改动」基线靠它;而写入要的是聚合版本:store 记着每次读到的存储视图
136
+ 在那个 revision 上的 `version`,写入时发这个版本;引擎拿着一个 store 没有版本的 revision 时,先重读一次——
137
+ 与视图当前不符的 revision 是 `CONFLICT`,带上视图现在的样子,什么都不发。写入的答复按它留下的版本从系统视图路由
138
+ 读回。服务端以重复请求拒绝的重试,会在系统路径上的重放路由里找到,但那条路由答的是快照,内容散列只有服务端算得出:
139
+ store 以系统视图**现在的样子**作答——除非这期间另一位管理员写过,那就是这次重试的结果。第一次已经把视图推过、手里的 revision 已不是视图当前的那个的重试,
140
+ 先问重放路由,再以 `CONFLICT` 拒绝;重试的系统视图创建,从系统视图路由读回作答,带标记、用内容散列。
141
+
142
+ ## 错误
143
+
144
+ 所有拒绝都是引擎的 `ViewStoreError`,按 Wow 的错误码读——只有回答里没有 store 认得的错误码时才看 HTTP 状态:
145
+
146
+ | 服务端 | 端口 |
147
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
148
+ | `CommandExpectVersionConflict`、`EventVersionConflict`、`SourcingVersionConflict`;没有认得的错误码时 409、412 | `CONFLICT` |
149
+ | `NotFound`(别的应用的视图也是)、`IllegalAccessDeletedAggregate`;没有认得的错误码时 404、410 | `NOT_FOUND` |
150
+ | `IllegalAccessOwnerAggregate`、`IllegalAccessSpaceAggregate`、`IllegalAccessQueryScope`、`SystemViewReadOnly`、`ViewEventStreamClosed`;没有认得的错误码时 401、403 | `FORBIDDEN` |
151
+ | `ViewInvalid`、`ViewAppRequired`、`ViewScopeRequired`、`BadRequest`、`CommandValidation`、`IllegalArgument`、`DuplicateAggregateId`、`QuerySchemaValidation`;没有认得的错误码时 400、422;fetcher 的拦截器没填的路径变量(什么都没发) | `INVALID` |
152
+ | `IllegalState`、`RequestTimeout`、`TooManyRequests`、`InternalServerError`、`QuerySchemaUnavailable`、`QuerySchemaConflict`;没有认得的错误码时其余状态;根本没有回答 | `UNAVAILABLE` |
153
+ | 没有视图存储的服务端(早于它发布的)答的 `404`:列表,或每处都 `404` 且服务端的系统视图也 `404` 的读写 | `UNSUPPORTED` |
154
+
155
+ 每个 `ViewStoreError` 都留着它的来处:请求本身的失败是 `cause`,服务端的 `errorCode` 是 `detail.code`(宿主据此分辨同为
156
+ `INVALID` 的 `ViewAppRequired` 与 `ViewInvalid`);服务端答了话的 `UNAVAILABLE`——5xx、它报的超时、不是 JSON 的页面——带
157
+ `reachable: true`,引擎据此说「服务端暂时无法处理」,而不是「无法连接服务端」。
158
+
159
+ 重复的请求 id(`DuplicateRequestId`)本身不是错误:store 去查第一次的结果(见上)。没填的路径变量是宿主的配置问题——
160
+ 拦截器没填 `{tenantId}`,或个人路径上的 `{ownerId}`——所以与服务端自己的 `ViewScopeRequired` 一样是 `INVALID`:
161
+ 请求一构造就是错的,重试发出的还是同一个。
162
+
163
+ 服务端因为有共享仪表盘显示着视图而拒绝收为个人时,错误的 `boards` 原样带上那几块看板的**标题**,由视图引擎用自己的话
164
+ 说出这次拒绝:以键写的标题仍是键,在显示拒绝的地方说成话。`message` 保留服务端的原话,供日志。
165
+
166
+ `WowViewStoreErrorCodes` 列出视图存储自己的错误码。
167
+
168
+ ## 测试
169
+
170
+ - `pnpm --filter @ahoo-wang/wow-view-store test`——对着假服务端的单元测试、公开面与 API 报告。
171
+ - 端口的一致性测试套件在 `typescript/integration-test`(`test/view-store/`)里对着视图存储服务端跑
172
+ `WowViewStore`,另有租户与应用的隔离测试。
173
+
174
+ 以 Apache License 2.0 许可。