@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 +201 -0
- package/README.md +227 -0
- package/README.zh-CN.md +174 -0
- package/dist/errors.d.ts +102 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +604 -0
- package/dist/index.es.js.map +1 -0
- package/dist/paths.d.ts +64 -0
- package/dist/wire.d.ts +78 -0
- package/dist/wowViewStore.d.ts +214 -0
- package/package.json +77 -0
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.
|
package/README.zh-CN.md
ADDED
|
@@ -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 许可。
|