@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.10
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/lib/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +55 -12
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +27 -6
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +97 -42
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +3 -0
- package/lib/locale/index.d.ts +6 -0
- package/lib/locale/zh-CN.json +3 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +15 -1
- package/lib/types.js +1 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +632 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +154 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +66 -11
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +34 -7
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowContext.ts +104 -36
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +3 -0
- package/src/locale/zh-CN.json +3 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +16 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +713 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +742 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { APIClient } from '@nocobase/sdk';
|
|
11
|
+
import type { ActionParams, IResource, RequestOptions } from '@nocobase/sdk';
|
|
12
|
+
import type { FlowContext } from '../flowContext';
|
|
13
|
+
import { getDataSourceKeyFromHeaders, markDataSourceDirty } from './dataSourceDirty';
|
|
14
|
+
|
|
15
|
+
type ResourceActionFn = (params?: ActionParams, opts?: unknown) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
export const SKIP_DATA_SOURCE_DIRTY = '__nocobaseSkipDataSourceDirty';
|
|
18
|
+
// Carries one logical mutation through APIClient's request/resource cross-dispatch.
|
|
19
|
+
const DIRTY_DISPATCH_TOKEN = Symbol('nocobaseDirtyDispatchToken');
|
|
20
|
+
|
|
21
|
+
type DirtyDispatchToken = {
|
|
22
|
+
marked: boolean;
|
|
23
|
+
requestKey?: string;
|
|
24
|
+
resourceKey?: string;
|
|
25
|
+
skip: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type DirtyDispatchFrame = {
|
|
29
|
+
key?: string;
|
|
30
|
+
token: DirtyDispatchToken;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ResourceRequestOptions = RequestOptions & {
|
|
34
|
+
resource?: unknown;
|
|
35
|
+
resourceOf?: unknown;
|
|
36
|
+
action?: unknown;
|
|
37
|
+
headers?: unknown;
|
|
38
|
+
[SKIP_DATA_SOURCE_DIRTY]?: boolean;
|
|
39
|
+
[DIRTY_DISPATCH_TOKEN]?: DirtyDispatchToken;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type DirtyResourceAction = {
|
|
43
|
+
dataSourceKey?: string;
|
|
44
|
+
resourceName: string;
|
|
45
|
+
actionName: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ApiUrlProvider = {
|
|
49
|
+
getApiUrl?: (pathname?: string) => string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type DirtyAwareAPIClient = APIClient & {
|
|
53
|
+
resource: APIClient['resource'];
|
|
54
|
+
request: APIClient['request'];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type APIClientRequestConfig = Parameters<APIClient['request']>[0];
|
|
58
|
+
|
|
59
|
+
const dirtyAwareApiClientCache = new WeakMap<object, WeakMap<object, APIClient>>();
|
|
60
|
+
const dirtyAwareApiClientProxies = new WeakSet<object>();
|
|
61
|
+
|
|
62
|
+
const MUTATING_RESOURCE_ACTIONS = [
|
|
63
|
+
'add',
|
|
64
|
+
'bulkdestroy',
|
|
65
|
+
'bulkupdate',
|
|
66
|
+
'create',
|
|
67
|
+
'delete',
|
|
68
|
+
'destroy',
|
|
69
|
+
'execute',
|
|
70
|
+
'firstorcreate',
|
|
71
|
+
'import',
|
|
72
|
+
'move',
|
|
73
|
+
'remove',
|
|
74
|
+
'save',
|
|
75
|
+
'saveastemplate',
|
|
76
|
+
'set',
|
|
77
|
+
'setfields',
|
|
78
|
+
'submit',
|
|
79
|
+
'update',
|
|
80
|
+
'updateorcreate',
|
|
81
|
+
'upsert',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function isApiClientLike(value: unknown): value is DirtyAwareAPIClient {
|
|
85
|
+
if (!value || typeof value !== 'object') {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const candidate = value as { resource?: unknown; request?: unknown };
|
|
89
|
+
return typeof candidate.resource === 'function' && typeof candidate.request === 'function';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isMutatingResourceAction(actionName: string): boolean {
|
|
93
|
+
const normalized = String(actionName || '').trim();
|
|
94
|
+
if (!normalized) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const baseActionName = normalized.split('/')[0];
|
|
98
|
+
const lowerBaseActionName = baseActionName.toLowerCase();
|
|
99
|
+
return MUTATING_RESOURCE_ACTIONS.some((action) => {
|
|
100
|
+
if (lowerBaseActionName === action) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (!lowerBaseActionName.startsWith(action) || baseActionName.length <= action.length) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const nextChar = baseActionName[action.length];
|
|
108
|
+
return nextChar === '-' || nextChar === '_' || (nextChar >= 'A' && nextChar <= 'Z');
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getCurrentOrigin(): string | undefined {
|
|
113
|
+
return typeof window === 'undefined' ? undefined : window.location?.origin;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseUrl(value: string, base?: string): URL | undefined {
|
|
117
|
+
try {
|
|
118
|
+
return new URL(value, base);
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stripSearchAndHash(path: string): string {
|
|
125
|
+
const index = path.search(/[?#]/);
|
|
126
|
+
return index === -1 ? path : path.slice(0, index);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stripKnownApiPrefix(path: string): string | undefined {
|
|
130
|
+
const cleanPath = stripSearchAndHash(path).trim();
|
|
131
|
+
if (!cleanPath) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const normalizedPath = cleanPath.replace(/^\/+/, '');
|
|
136
|
+
if (!normalizedPath || normalizedPath === 'api') {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (normalizedPath.startsWith('api/')) {
|
|
140
|
+
return normalizedPath.slice('api/'.length);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return normalizedPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizePathname(pathname: string) {
|
|
147
|
+
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getAppApiUrl(app?: ApiUrlProvider): URL | undefined {
|
|
151
|
+
if (!app?.getApiUrl) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
return parseUrl(app.getApiUrl(), getCurrentOrigin());
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stripConfiguredApiPrefix(path: string, apiPathname: string): string | undefined {
|
|
163
|
+
const cleanPath = stripSearchAndHash(path).trim();
|
|
164
|
+
if (!cleanPath.startsWith('/')) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const apiPath = normalizePathname(apiPathname);
|
|
169
|
+
const requestPath = normalizePathname(cleanPath);
|
|
170
|
+
if (!requestPath.startsWith(apiPath)) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const apiPathWithoutTrailingSlash = apiPath.replace(/\/$/, '');
|
|
175
|
+
return cleanPath.slice(apiPathWithoutTrailingSlash.length).replace(/^\/+/, '') || undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getDirtyResourcePathFromAbsoluteUrl(url: URL, app?: ApiUrlProvider): string | undefined {
|
|
179
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (app?.getApiUrl) {
|
|
184
|
+
const apiUrl = getAppApiUrl(app);
|
|
185
|
+
if (!apiUrl || url.origin !== apiUrl.origin) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return stripConfiguredApiPrefix(url.pathname, apiUrl.pathname);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const currentOrigin = getCurrentOrigin();
|
|
193
|
+
if (!currentOrigin || url.origin !== currentOrigin) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return stripKnownApiPrefix(url.pathname);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getDirtyResourcePathFromUrl(url: unknown, context: FlowContext): string | undefined {
|
|
201
|
+
if (typeof url !== 'string') {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const trimmedUrl = url.trim();
|
|
206
|
+
if (!trimmedUrl || trimmedUrl.startsWith('//')) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (/^https?:\/\//i.test(trimmedUrl)) {
|
|
211
|
+
const parsedUrl = parseUrl(trimmedUrl);
|
|
212
|
+
if (!parsedUrl) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
return getDirtyResourcePathFromAbsoluteUrl(parsedUrl, context.app as ApiUrlProvider | undefined);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const appApiUrl = getAppApiUrl(context.app as ApiUrlProvider | undefined);
|
|
219
|
+
const configuredResourcePath = appApiUrl ? stripConfiguredApiPrefix(trimmedUrl, appApiUrl.pathname) : undefined;
|
|
220
|
+
if (configuredResourcePath) {
|
|
221
|
+
return configuredResourcePath;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return stripKnownApiPrefix(trimmedUrl);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function decodeResourcePathSegment(segment: string): string {
|
|
228
|
+
try {
|
|
229
|
+
return decodeURIComponent(segment);
|
|
230
|
+
} catch {
|
|
231
|
+
return segment;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getDataSourceKeyFromResourceOf(resourceOf: unknown): string | undefined {
|
|
236
|
+
const dataSourceKey = String(resourceOf ?? '').trim();
|
|
237
|
+
return dataSourceKey || undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseResourceActionFromSegments(segments: string[]): DirtyResourceAction | undefined {
|
|
241
|
+
const resourceSegments: string[] = [];
|
|
242
|
+
let actionName: string | undefined;
|
|
243
|
+
let actionSegmentIndex = -1;
|
|
244
|
+
|
|
245
|
+
for (let index = 0; index < segments.length; index += 2) {
|
|
246
|
+
const segment = segments[index];
|
|
247
|
+
const actionDelimiterIndex = segment.lastIndexOf(':');
|
|
248
|
+
const resourceSegment = actionDelimiterIndex === -1 ? segment : segment.slice(0, actionDelimiterIndex);
|
|
249
|
+
if (!resourceSegment) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
resourceSegments.push(decodeResourcePathSegment(resourceSegment));
|
|
254
|
+
if (actionDelimiterIndex !== -1) {
|
|
255
|
+
actionName = decodeResourcePathSegment(segment.slice(actionDelimiterIndex + 1)).trim();
|
|
256
|
+
actionSegmentIndex = index;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!actionName || !resourceSegments.length) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (segments.length > actionSegmentIndex + 2) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
resourceName: resourceSegments.join('.'),
|
|
271
|
+
actionName,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parseDirtyResourceActionFromUrl(url: unknown, context: FlowContext): DirtyResourceAction | undefined {
|
|
276
|
+
const resourcePath = getDirtyResourcePathFromUrl(url, context);
|
|
277
|
+
if (!resourcePath) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const segments = stripSearchAndHash(resourcePath).split('/').filter(Boolean);
|
|
282
|
+
const firstSegment = decodeResourcePathSegment(segments[0] || '');
|
|
283
|
+
if (firstSegment === 'dataSources' && segments.length >= 3) {
|
|
284
|
+
const dataSourceKey = getDataSourceKeyFromResourceOf(decodeResourcePathSegment(segments[1]));
|
|
285
|
+
const parsed = parseResourceActionFromSegments(segments.slice(2));
|
|
286
|
+
if (dataSourceKey && parsed) {
|
|
287
|
+
return {
|
|
288
|
+
...parsed,
|
|
289
|
+
dataSourceKey,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return parseResourceActionFromSegments(segments);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function resolveDirtyResourceActionFromResource(
|
|
298
|
+
resourceName: string,
|
|
299
|
+
resourceOf: unknown,
|
|
300
|
+
actionName: string,
|
|
301
|
+
context: FlowContext,
|
|
302
|
+
): DirtyResourceAction | undefined {
|
|
303
|
+
const normalizedResourceName = resourceName.trim();
|
|
304
|
+
const normalizedActionName = actionName.trim();
|
|
305
|
+
if (!normalizedResourceName || !normalizedActionName) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (normalizedResourceName.includes('/')) {
|
|
310
|
+
const parsed = parseDirtyResourceActionFromUrl(`${normalizedResourceName}:${normalizedActionName}`, context);
|
|
311
|
+
if (parsed) {
|
|
312
|
+
return parsed;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const dataSourcesPrefix = 'dataSources.';
|
|
317
|
+
if (normalizedResourceName.startsWith(dataSourcesPrefix)) {
|
|
318
|
+
const dataSourceKey = getDataSourceKeyFromResourceOf(resourceOf);
|
|
319
|
+
const nestedResourceName = normalizedResourceName.slice(dataSourcesPrefix.length).trim();
|
|
320
|
+
if (dataSourceKey && nestedResourceName) {
|
|
321
|
+
return {
|
|
322
|
+
dataSourceKey,
|
|
323
|
+
resourceName: nestedResourceName,
|
|
324
|
+
actionName: normalizedActionName,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
resourceName: normalizedResourceName,
|
|
331
|
+
actionName: normalizedActionName,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function resolveDirtyResourceAction(
|
|
336
|
+
options: ResourceRequestOptions,
|
|
337
|
+
context: FlowContext,
|
|
338
|
+
): DirtyResourceAction | undefined {
|
|
339
|
+
const resourceName = typeof options?.resource === 'string' ? options.resource : undefined;
|
|
340
|
+
const actionName = typeof options?.action === 'string' ? options.action : undefined;
|
|
341
|
+
if (resourceName && actionName) {
|
|
342
|
+
return resolveDirtyResourceActionFromResource(resourceName, options.resourceOf, actionName, context);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return parseDirtyResourceActionFromUrl(options?.url, context);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function getDirtyResourceActionDispatchKey(
|
|
349
|
+
dirtyResourceAction: DirtyResourceAction | undefined,
|
|
350
|
+
headers: unknown,
|
|
351
|
+
): string | undefined {
|
|
352
|
+
if (!dirtyResourceAction) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
return JSON.stringify([
|
|
356
|
+
dirtyResourceAction.dataSourceKey || getDataSourceKeyFromHeaders(headers),
|
|
357
|
+
dirtyResourceAction.resourceName,
|
|
358
|
+
dirtyResourceAction.actionName,
|
|
359
|
+
]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function getRequestDispatchKey(options: ResourceRequestOptions, context: FlowContext): string | undefined {
|
|
363
|
+
return getDirtyResourceActionDispatchKey(resolveDirtyResourceAction(options, context), options.headers);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getResourceDispatchKey(name: unknown, of: unknown, headers: unknown): string | undefined {
|
|
367
|
+
const resourceName = String(name ?? '').trim();
|
|
368
|
+
if (!resourceName) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
return JSON.stringify([resourceName, String(of ?? ''), getDataSourceKeyFromHeaders(headers)]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getMatchingRequestToken(
|
|
375
|
+
token: DirtyDispatchToken | undefined,
|
|
376
|
+
requestKey: string | undefined,
|
|
377
|
+
): DirtyDispatchToken | undefined {
|
|
378
|
+
// A matching key prevents helper requests inside an override from sharing the outer mutation token.
|
|
379
|
+
if (!token || !requestKey || (token.requestKey && token.requestKey !== requestKey)) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return token;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getMatchingResourceToken(
|
|
386
|
+
token: DirtyDispatchToken | undefined,
|
|
387
|
+
resourceKey: string | undefined,
|
|
388
|
+
): DirtyDispatchToken | undefined {
|
|
389
|
+
if (!token || !resourceKey || (token.resourceKey && token.resourceKey !== resourceKey)) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return token;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function markResourceActionDataSourceDirty(
|
|
396
|
+
context: FlowContext,
|
|
397
|
+
dirtyResourceAction: DirtyResourceAction,
|
|
398
|
+
headers: unknown,
|
|
399
|
+
) {
|
|
400
|
+
markDataSourceDirty({
|
|
401
|
+
engine: context.engine,
|
|
402
|
+
dataSourceKey: dirtyResourceAction.dataSourceKey || getDataSourceKeyFromHeaders(headers),
|
|
403
|
+
resourceName: dirtyResourceAction.resourceName,
|
|
404
|
+
includePreviousEngines: true,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function markResourceActionDataSourceDirtyOnce(
|
|
409
|
+
token: DirtyDispatchToken,
|
|
410
|
+
context: FlowContext,
|
|
411
|
+
dirtyResourceAction: DirtyResourceAction | undefined,
|
|
412
|
+
headers: unknown,
|
|
413
|
+
) {
|
|
414
|
+
if (token.skip || token.marked || !dirtyResourceAction || !isMutatingResourceAction(dirtyResourceAction.actionName)) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
token.marked = true;
|
|
418
|
+
markResourceActionDataSourceDirty(context, dirtyResourceAction, headers);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isObjectRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
|
422
|
+
return !!value && typeof value === 'object';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function createDirtyAwareResource(
|
|
426
|
+
context: FlowContext,
|
|
427
|
+
resource: IResource,
|
|
428
|
+
resourceName: string,
|
|
429
|
+
resourceOf: unknown,
|
|
430
|
+
headers: unknown,
|
|
431
|
+
requestTokenStack: DirtyDispatchFrame[],
|
|
432
|
+
parentToken?: DirtyDispatchToken,
|
|
433
|
+
): IResource {
|
|
434
|
+
return new Proxy(resource, {
|
|
435
|
+
get(target, prop, receiver) {
|
|
436
|
+
const original = Reflect.get(target, prop, receiver);
|
|
437
|
+
if (typeof prop !== 'string' || typeof original !== 'function' || !isMutatingResourceAction(prop)) {
|
|
438
|
+
return original;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const action = original as ResourceActionFn;
|
|
442
|
+
return async (...args: Parameters<ResourceActionFn>) => {
|
|
443
|
+
const actionOptions = isObjectRecord(args[1]) ? args[1] : undefined;
|
|
444
|
+
const dirtyResourceAction = resolveDirtyResourceActionFromResource(resourceName, resourceOf, prop, context);
|
|
445
|
+
const requestKey = getDirtyResourceActionDispatchKey(dirtyResourceAction, headers);
|
|
446
|
+
const resourceKey = getResourceDispatchKey(resourceName, resourceOf, headers);
|
|
447
|
+
const inheritedToken =
|
|
448
|
+
getMatchingRequestToken(
|
|
449
|
+
actionOptions?.[DIRTY_DISPATCH_TOKEN] as DirtyDispatchToken | undefined,
|
|
450
|
+
requestKey,
|
|
451
|
+
) || getMatchingRequestToken(parentToken, requestKey);
|
|
452
|
+
const token = inheritedToken || { marked: false, skip: false };
|
|
453
|
+
const ownsToken = !inheritedToken;
|
|
454
|
+
token.requestKey ||= requestKey;
|
|
455
|
+
token.resourceKey ||= resourceKey;
|
|
456
|
+
if (actionOptions?.[SKIP_DATA_SOURCE_DIRTY]) {
|
|
457
|
+
token.skip = true;
|
|
458
|
+
}
|
|
459
|
+
const forwardedArgs: Parameters<ResourceActionFn> =
|
|
460
|
+
actionOptions || args[1] == null
|
|
461
|
+
? [
|
|
462
|
+
args[0],
|
|
463
|
+
{
|
|
464
|
+
...actionOptions,
|
|
465
|
+
[DIRTY_DISPATCH_TOKEN]: token,
|
|
466
|
+
},
|
|
467
|
+
]
|
|
468
|
+
: args;
|
|
469
|
+
let actionResult: Promise<unknown>;
|
|
470
|
+
requestTokenStack.push({ key: requestKey, token });
|
|
471
|
+
try {
|
|
472
|
+
actionResult = Reflect.apply(action, receiver, forwardedArgs);
|
|
473
|
+
} finally {
|
|
474
|
+
requestTokenStack.pop();
|
|
475
|
+
}
|
|
476
|
+
const result = await actionResult;
|
|
477
|
+
if (ownsToken) {
|
|
478
|
+
markResourceActionDataSourceDirtyOnce(token, context, dirtyResourceAction, headers);
|
|
479
|
+
}
|
|
480
|
+
return result;
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function createDirtyAwareApiClient(api: DirtyAwareAPIClient, context: FlowContext): APIClient {
|
|
487
|
+
const baseResource = api.resource;
|
|
488
|
+
const baseRequest = api.request;
|
|
489
|
+
// SDK methods use the dispatch receiver; custom methods keep the original instance for private fields and state.
|
|
490
|
+
const shouldUseResourceDispatchReceiver = baseResource === APIClient.prototype.resource;
|
|
491
|
+
const shouldUseRequestDispatchReceiver = baseRequest === APIClient.prototype.request;
|
|
492
|
+
// Stacks cover synchronous delegation; the symbol token survives async overrides that forward arguments.
|
|
493
|
+
const resourceTokenStack: DirtyDispatchFrame[] = [];
|
|
494
|
+
const requestTokenStack: DirtyDispatchFrame[] = [];
|
|
495
|
+
let hasResourceOverride = false;
|
|
496
|
+
let resourceOverride: unknown;
|
|
497
|
+
let hasRequestOverride = false;
|
|
498
|
+
let requestOverride: unknown;
|
|
499
|
+
|
|
500
|
+
const getCurrentResource = () => (hasResourceOverride ? resourceOverride : resource) as APIClient['resource'];
|
|
501
|
+
const getCurrentRequest = () => (hasRequestOverride ? requestOverride : request) as APIClient['request'];
|
|
502
|
+
|
|
503
|
+
const dispatchResource = (
|
|
504
|
+
token: DirtyDispatchToken | undefined,
|
|
505
|
+
args: Parameters<APIClient['resource']>,
|
|
506
|
+
): IResource => {
|
|
507
|
+
const resourceKey = getResourceDispatchKey(args[0], args[1], args[2]);
|
|
508
|
+
const activeToken = getMatchingResourceToken(token, resourceKey);
|
|
509
|
+
const shouldWrapActions = !!activeToken && hasResourceOverride;
|
|
510
|
+
if (activeToken) {
|
|
511
|
+
activeToken.resourceKey ||= resourceKey;
|
|
512
|
+
resourceTokenStack.push({ key: resourceKey, token: activeToken });
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const resourceInstance = Reflect.apply(getCurrentResource(), proxy, args);
|
|
516
|
+
if (!shouldWrapActions || !activeToken) {
|
|
517
|
+
return resourceInstance;
|
|
518
|
+
}
|
|
519
|
+
return new Proxy(resourceInstance, {
|
|
520
|
+
get(target, prop, receiver) {
|
|
521
|
+
const original = Reflect.get(target, prop, receiver);
|
|
522
|
+
if (typeof prop !== 'string' || typeof original !== 'function' || !isMutatingResourceAction(prop)) {
|
|
523
|
+
return original;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const action = original as ResourceActionFn;
|
|
527
|
+
return (...actionArgs: Parameters<ResourceActionFn>) => {
|
|
528
|
+
const actionOptions = isObjectRecord(actionArgs[1]) ? actionArgs[1] : undefined;
|
|
529
|
+
const forwardedArgs: Parameters<ResourceActionFn> =
|
|
530
|
+
actionOptions || actionArgs[1] == null
|
|
531
|
+
? [
|
|
532
|
+
actionArgs[0],
|
|
533
|
+
{
|
|
534
|
+
...actionOptions,
|
|
535
|
+
[DIRTY_DISPATCH_TOKEN]: activeToken,
|
|
536
|
+
},
|
|
537
|
+
]
|
|
538
|
+
: actionArgs;
|
|
539
|
+
resourceTokenStack.push({ key: resourceKey, token: activeToken });
|
|
540
|
+
requestTokenStack.push({ key: activeToken.requestKey, token: activeToken });
|
|
541
|
+
try {
|
|
542
|
+
return Reflect.apply(action, receiver, forwardedArgs);
|
|
543
|
+
} finally {
|
|
544
|
+
requestTokenStack.pop();
|
|
545
|
+
resourceTokenStack.pop();
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
} finally {
|
|
551
|
+
if (activeToken) {
|
|
552
|
+
resourceTokenStack.pop();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const createDispatchReceiver = (token?: DirtyDispatchToken): DirtyAwareAPIClient => {
|
|
558
|
+
const receiver = Object.create(api) as DirtyAwareAPIClient;
|
|
559
|
+
Object.defineProperties(receiver, {
|
|
560
|
+
request: {
|
|
561
|
+
configurable: true,
|
|
562
|
+
value: (<T, R, D>(config: APIClientRequestConfig): Promise<R> => {
|
|
563
|
+
const options = config as ResourceRequestOptions;
|
|
564
|
+
const requestKey = getRequestDispatchKey(options, context);
|
|
565
|
+
const stackFrame = requestTokenStack.at(-1);
|
|
566
|
+
const activeToken =
|
|
567
|
+
getMatchingRequestToken(options?.[DIRTY_DISPATCH_TOKEN], requestKey) ||
|
|
568
|
+
(requestKey && stackFrame?.key === requestKey ? stackFrame.token : undefined) ||
|
|
569
|
+
getMatchingRequestToken(token, requestKey);
|
|
570
|
+
const { [DIRTY_DISPATCH_TOKEN]: _dirtyDispatchToken, ...cleanOptions } = options;
|
|
571
|
+
const configWithToken = activeToken ? { ...cleanOptions, [DIRTY_DISPATCH_TOKEN]: activeToken } : cleanOptions;
|
|
572
|
+
if (activeToken) {
|
|
573
|
+
activeToken.requestKey ||= requestKey;
|
|
574
|
+
requestTokenStack.push({ key: requestKey, token: activeToken });
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
return Reflect.apply(getCurrentRequest(), proxy, [configWithToken]) as Promise<R>;
|
|
578
|
+
} finally {
|
|
579
|
+
if (activeToken) {
|
|
580
|
+
requestTokenStack.pop();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}) as APIClient['request'],
|
|
584
|
+
},
|
|
585
|
+
resource: {
|
|
586
|
+
configurable: true,
|
|
587
|
+
value: ((...args: Parameters<APIClient['resource']>) => {
|
|
588
|
+
const resourceKey = getResourceDispatchKey(args[0], args[1], args[2]);
|
|
589
|
+
const stackFrame = resourceTokenStack.at(-1);
|
|
590
|
+
const activeToken =
|
|
591
|
+
getMatchingResourceToken(token, resourceKey) ||
|
|
592
|
+
(resourceKey && stackFrame?.key === resourceKey ? stackFrame.token : undefined);
|
|
593
|
+
return dispatchResource(activeToken, args);
|
|
594
|
+
}) as APIClient['resource'],
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
return receiver;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const resource: APIClient['resource'] = (name, of, headers, cancel) => {
|
|
601
|
+
const resourceKey = getResourceDispatchKey(name, of, headers);
|
|
602
|
+
const stackFrame = resourceTokenStack.at(-1);
|
|
603
|
+
const parentToken = resourceKey && stackFrame?.key === resourceKey ? stackFrame.token : undefined;
|
|
604
|
+
const receiver = createDispatchReceiver(parentToken);
|
|
605
|
+
const resourceInstance = Reflect.apply(baseResource, shouldUseResourceDispatchReceiver ? receiver : api, [
|
|
606
|
+
name,
|
|
607
|
+
of,
|
|
608
|
+
headers,
|
|
609
|
+
cancel,
|
|
610
|
+
]);
|
|
611
|
+
return createDirtyAwareResource(context, resourceInstance, name, of, headers, requestTokenStack, parentToken);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const request = (<T, R, D>(config: APIClientRequestConfig): Promise<R> => {
|
|
615
|
+
const options = config as ResourceRequestOptions;
|
|
616
|
+
const requestKey = getRequestDispatchKey(options, context);
|
|
617
|
+
const stackFrame = requestTokenStack.at(-1);
|
|
618
|
+
const inheritedToken =
|
|
619
|
+
getMatchingRequestToken(options?.[DIRTY_DISPATCH_TOKEN], requestKey) ||
|
|
620
|
+
(requestKey && stackFrame?.key === requestKey ? stackFrame.token : undefined);
|
|
621
|
+
const token = inheritedToken || { marked: false, skip: false };
|
|
622
|
+
const ownsToken = !inheritedToken;
|
|
623
|
+
token.requestKey ||= requestKey;
|
|
624
|
+
if (typeof options.resource === 'string') {
|
|
625
|
+
token.resourceKey ||= getResourceDispatchKey(options.resource, options.resourceOf, options.headers);
|
|
626
|
+
}
|
|
627
|
+
const skipDataSourceDirty = options?.[SKIP_DATA_SOURCE_DIRTY];
|
|
628
|
+
if (skipDataSourceDirty) {
|
|
629
|
+
token.skip = true;
|
|
630
|
+
}
|
|
631
|
+
const dirtyResourceAction = resolveDirtyResourceAction(options, context);
|
|
632
|
+
const {
|
|
633
|
+
[DIRTY_DISPATCH_TOKEN]: _dirtyDispatchToken,
|
|
634
|
+
[SKIP_DATA_SOURCE_DIRTY]: _skipDataSourceDirty,
|
|
635
|
+
...cleanConfig
|
|
636
|
+
} = options;
|
|
637
|
+
const receiver = createDispatchReceiver(token);
|
|
638
|
+
return (
|
|
639
|
+
Reflect.apply(baseRequest, shouldUseRequestDispatchReceiver ? receiver : api, [cleanConfig]) as Promise<R>
|
|
640
|
+
).then((result) => {
|
|
641
|
+
if (ownsToken) {
|
|
642
|
+
markResourceActionDataSourceDirtyOnce(token, context, dirtyResourceAction, options.headers);
|
|
643
|
+
}
|
|
644
|
+
return result;
|
|
645
|
+
});
|
|
646
|
+
}) as APIClient['request'];
|
|
647
|
+
|
|
648
|
+
const isLockedOwnProperty = (target: DirtyAwareAPIClient, prop: PropertyKey) => {
|
|
649
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
650
|
+
return !!descriptor && !descriptor.configurable;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const proxy = new Proxy(api, {
|
|
654
|
+
get(target, prop, receiver) {
|
|
655
|
+
if (prop === 'resource') {
|
|
656
|
+
if (isLockedOwnProperty(target, prop)) {
|
|
657
|
+
return Reflect.get(target, prop, receiver);
|
|
658
|
+
}
|
|
659
|
+
return hasResourceOverride ? resourceOverride : resource;
|
|
660
|
+
}
|
|
661
|
+
if (prop === 'request') {
|
|
662
|
+
if (isLockedOwnProperty(target, prop)) {
|
|
663
|
+
return Reflect.get(target, prop, receiver);
|
|
664
|
+
}
|
|
665
|
+
return hasRequestOverride ? requestOverride : request;
|
|
666
|
+
}
|
|
667
|
+
return Reflect.get(target, prop, receiver);
|
|
668
|
+
},
|
|
669
|
+
set(target, prop, value, receiver) {
|
|
670
|
+
if (prop === 'resource') {
|
|
671
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
672
|
+
if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
hasResourceOverride = true;
|
|
676
|
+
resourceOverride = value;
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
if (prop === 'request') {
|
|
680
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
681
|
+
if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
hasRequestOverride = true;
|
|
685
|
+
requestOverride = value;
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
return Reflect.set(target, prop, value, receiver);
|
|
689
|
+
},
|
|
690
|
+
deleteProperty(target, prop) {
|
|
691
|
+
if (prop !== 'resource' && prop !== 'request') {
|
|
692
|
+
return Reflect.deleteProperty(target, prop);
|
|
693
|
+
}
|
|
694
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
|
695
|
+
if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
if (prop === 'resource') {
|
|
699
|
+
hasResourceOverride = false;
|
|
700
|
+
resourceOverride = undefined;
|
|
701
|
+
} else {
|
|
702
|
+
hasRequestOverride = false;
|
|
703
|
+
requestOverride = undefined;
|
|
704
|
+
}
|
|
705
|
+
return true;
|
|
706
|
+
},
|
|
707
|
+
defineProperty(target, prop, descriptor) {
|
|
708
|
+
if (prop === 'resource' || prop === 'request') {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
return Reflect.defineProperty(target, prop, descriptor);
|
|
712
|
+
},
|
|
713
|
+
}) as APIClient;
|
|
714
|
+
dirtyAwareApiClientProxies.add(proxy);
|
|
715
|
+
return proxy;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function getDirtyAwareApiClient(value: unknown, context: FlowContext): unknown {
|
|
719
|
+
if (!isApiClientLike(value)) {
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (dirtyAwareApiClientProxies.has(value)) {
|
|
724
|
+
return value;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const api = value as unknown as APIClient;
|
|
728
|
+
let contextCache = dirtyAwareApiClientCache.get(api);
|
|
729
|
+
if (!contextCache) {
|
|
730
|
+
contextCache = new WeakMap<object, APIClient>();
|
|
731
|
+
dirtyAwareApiClientCache.set(api, contextCache);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const cached = contextCache.get(context);
|
|
735
|
+
if (cached) {
|
|
736
|
+
return cached;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const wrapped = createDirtyAwareApiClient(value, context);
|
|
740
|
+
contextCache.set(context, wrapped);
|
|
741
|
+
return wrapped;
|
|
742
|
+
}
|