@nocobase/flow-engine 2.2.0-beta.10 → 2.2.0-beta.11

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.
@@ -31,8 +31,10 @@ __export(dirtyAwareApiClient_exports, {
31
31
  getDirtyAwareApiClient: () => getDirtyAwareApiClient
32
32
  });
33
33
  module.exports = __toCommonJS(dirtyAwareApiClient_exports);
34
+ var import_sdk = require("@nocobase/sdk");
34
35
  var import_dataSourceDirty = require("./dataSourceDirty");
35
36
  const SKIP_DATA_SOURCE_DIRTY = "__nocobaseSkipDataSourceDirty";
37
+ const DIRTY_DISPATCH_TOKEN = Symbol("nocobaseDirtyDispatchToken");
36
38
  const dirtyAwareApiClientCache = /* @__PURE__ */ new WeakMap();
37
39
  const dirtyAwareApiClientProxies = /* @__PURE__ */ new WeakSet();
38
40
  const MUTATING_RESOURCE_ACTIONS = [
@@ -288,6 +290,43 @@ function resolveDirtyResourceAction(options, context) {
288
290
  return parseDirtyResourceActionFromUrl(options == null ? void 0 : options.url, context);
289
291
  }
290
292
  __name(resolveDirtyResourceAction, "resolveDirtyResourceAction");
293
+ function getDirtyResourceActionDispatchKey(dirtyResourceAction, headers) {
294
+ if (!dirtyResourceAction) {
295
+ return void 0;
296
+ }
297
+ return JSON.stringify([
298
+ dirtyResourceAction.dataSourceKey || (0, import_dataSourceDirty.getDataSourceKeyFromHeaders)(headers),
299
+ dirtyResourceAction.resourceName,
300
+ dirtyResourceAction.actionName
301
+ ]);
302
+ }
303
+ __name(getDirtyResourceActionDispatchKey, "getDirtyResourceActionDispatchKey");
304
+ function getRequestDispatchKey(options, context) {
305
+ return getDirtyResourceActionDispatchKey(resolveDirtyResourceAction(options, context), options.headers);
306
+ }
307
+ __name(getRequestDispatchKey, "getRequestDispatchKey");
308
+ function getResourceDispatchKey(name, of, headers) {
309
+ const resourceName = String(name ?? "").trim();
310
+ if (!resourceName) {
311
+ return void 0;
312
+ }
313
+ return JSON.stringify([resourceName, String(of ?? ""), (0, import_dataSourceDirty.getDataSourceKeyFromHeaders)(headers)]);
314
+ }
315
+ __name(getResourceDispatchKey, "getResourceDispatchKey");
316
+ function getMatchingRequestToken(token, requestKey) {
317
+ if (!token || !requestKey || token.requestKey && token.requestKey !== requestKey) {
318
+ return void 0;
319
+ }
320
+ return token;
321
+ }
322
+ __name(getMatchingRequestToken, "getMatchingRequestToken");
323
+ function getMatchingResourceToken(token, resourceKey) {
324
+ if (!token || !resourceKey || token.resourceKey && token.resourceKey !== resourceKey) {
325
+ return void 0;
326
+ }
327
+ return token;
328
+ }
329
+ __name(getMatchingResourceToken, "getMatchingResourceToken");
291
330
  function markResourceActionDataSourceDirty(context, dirtyResourceAction, headers) {
292
331
  (0, import_dataSourceDirty.markDataSourceDirty)({
293
332
  engine: context.engine,
@@ -297,7 +336,19 @@ function markResourceActionDataSourceDirty(context, dirtyResourceAction, headers
297
336
  });
298
337
  }
299
338
  __name(markResourceActionDataSourceDirty, "markResourceActionDataSourceDirty");
300
- function createDirtyAwareResource(context, resource, resourceName, resourceOf, headers) {
339
+ function markResourceActionDataSourceDirtyOnce(token, context, dirtyResourceAction, headers) {
340
+ if (token.skip || token.marked || !dirtyResourceAction || !isMutatingResourceAction(dirtyResourceAction.actionName)) {
341
+ return;
342
+ }
343
+ token.marked = true;
344
+ markResourceActionDataSourceDirty(context, dirtyResourceAction, headers);
345
+ }
346
+ __name(markResourceActionDataSourceDirtyOnce, "markResourceActionDataSourceDirtyOnce");
347
+ function isObjectRecord(value) {
348
+ return !!value && typeof value === "object";
349
+ }
350
+ __name(isObjectRecord, "isObjectRecord");
351
+ function createDirtyAwareResource(context, resource, resourceName, resourceOf, headers, requestTokenStack, parentToken) {
301
352
  return new Proxy(resource, {
302
353
  get(target, prop, receiver) {
303
354
  const original = Reflect.get(target, prop, receiver);
@@ -306,10 +357,38 @@ function createDirtyAwareResource(context, resource, resourceName, resourceOf, h
306
357
  }
307
358
  const action = original;
308
359
  return async (...args) => {
309
- const result = await action(...args);
360
+ const actionOptions = isObjectRecord(args[1]) ? args[1] : void 0;
310
361
  const dirtyResourceAction = resolveDirtyResourceActionFromResource(resourceName, resourceOf, prop, context);
311
- if (dirtyResourceAction) {
312
- markResourceActionDataSourceDirty(context, dirtyResourceAction, headers);
362
+ const requestKey = getDirtyResourceActionDispatchKey(dirtyResourceAction, headers);
363
+ const resourceKey = getResourceDispatchKey(resourceName, resourceOf, headers);
364
+ const inheritedToken = getMatchingRequestToken(
365
+ actionOptions == null ? void 0 : actionOptions[DIRTY_DISPATCH_TOKEN],
366
+ requestKey
367
+ ) || getMatchingRequestToken(parentToken, requestKey);
368
+ const token = inheritedToken || { marked: false, skip: false };
369
+ const ownsToken = !inheritedToken;
370
+ token.requestKey ||= requestKey;
371
+ token.resourceKey ||= resourceKey;
372
+ if (actionOptions == null ? void 0 : actionOptions[SKIP_DATA_SOURCE_DIRTY]) {
373
+ token.skip = true;
374
+ }
375
+ const forwardedArgs = actionOptions || args[1] == null ? [
376
+ args[0],
377
+ {
378
+ ...actionOptions,
379
+ [DIRTY_DISPATCH_TOKEN]: token
380
+ }
381
+ ] : args;
382
+ let actionResult;
383
+ requestTokenStack.push({ key: requestKey, token });
384
+ try {
385
+ actionResult = Reflect.apply(action, receiver, forwardedArgs);
386
+ } finally {
387
+ requestTokenStack.pop();
388
+ }
389
+ const result = await actionResult;
390
+ if (ownsToken) {
391
+ markResourceActionDataSourceDirtyOnce(token, context, dirtyResourceAction, headers);
313
392
  }
314
393
  return result;
315
394
  };
@@ -318,31 +397,206 @@ function createDirtyAwareResource(context, resource, resourceName, resourceOf, h
318
397
  }
319
398
  __name(createDirtyAwareResource, "createDirtyAwareResource");
320
399
  function createDirtyAwareApiClient(api, context) {
400
+ const baseResource = api.resource;
401
+ const baseRequest = api.request;
402
+ const shouldUseResourceDispatchReceiver = baseResource === import_sdk.APIClient.prototype.resource;
403
+ const shouldUseRequestDispatchReceiver = baseRequest === import_sdk.APIClient.prototype.request;
404
+ const resourceTokenStack = [];
405
+ const requestTokenStack = [];
406
+ let hasResourceOverride = false;
407
+ let resourceOverride;
408
+ let hasRequestOverride = false;
409
+ let requestOverride;
410
+ const getCurrentResource = /* @__PURE__ */ __name(() => hasResourceOverride ? resourceOverride : resource, "getCurrentResource");
411
+ const getCurrentRequest = /* @__PURE__ */ __name(() => hasRequestOverride ? requestOverride : request, "getCurrentRequest");
412
+ const dispatchResource = /* @__PURE__ */ __name((token, args) => {
413
+ const resourceKey = getResourceDispatchKey(args[0], args[1], args[2]);
414
+ const activeToken = getMatchingResourceToken(token, resourceKey);
415
+ const shouldWrapActions = !!activeToken && hasResourceOverride;
416
+ if (activeToken) {
417
+ activeToken.resourceKey ||= resourceKey;
418
+ resourceTokenStack.push({ key: resourceKey, token: activeToken });
419
+ }
420
+ try {
421
+ const resourceInstance = Reflect.apply(getCurrentResource(), proxy, args);
422
+ if (!shouldWrapActions || !activeToken) {
423
+ return resourceInstance;
424
+ }
425
+ return new Proxy(resourceInstance, {
426
+ get(target, prop, receiver) {
427
+ const original = Reflect.get(target, prop, receiver);
428
+ if (typeof prop !== "string" || typeof original !== "function" || !isMutatingResourceAction(prop)) {
429
+ return original;
430
+ }
431
+ const action = original;
432
+ return (...actionArgs) => {
433
+ const actionOptions = isObjectRecord(actionArgs[1]) ? actionArgs[1] : void 0;
434
+ const forwardedArgs = actionOptions || actionArgs[1] == null ? [
435
+ actionArgs[0],
436
+ {
437
+ ...actionOptions,
438
+ [DIRTY_DISPATCH_TOKEN]: activeToken
439
+ }
440
+ ] : actionArgs;
441
+ resourceTokenStack.push({ key: resourceKey, token: activeToken });
442
+ requestTokenStack.push({ key: activeToken.requestKey, token: activeToken });
443
+ try {
444
+ return Reflect.apply(action, receiver, forwardedArgs);
445
+ } finally {
446
+ requestTokenStack.pop();
447
+ resourceTokenStack.pop();
448
+ }
449
+ };
450
+ }
451
+ });
452
+ } finally {
453
+ if (activeToken) {
454
+ resourceTokenStack.pop();
455
+ }
456
+ }
457
+ }, "dispatchResource");
458
+ const createDispatchReceiver = /* @__PURE__ */ __name((token) => {
459
+ const receiver = Object.create(api);
460
+ Object.defineProperties(receiver, {
461
+ request: {
462
+ configurable: true,
463
+ value: /* @__PURE__ */ __name((config) => {
464
+ const options = config;
465
+ const requestKey = getRequestDispatchKey(options, context);
466
+ const stackFrame = requestTokenStack.at(-1);
467
+ const activeToken = getMatchingRequestToken(options == null ? void 0 : options[DIRTY_DISPATCH_TOKEN], requestKey) || (requestKey && (stackFrame == null ? void 0 : stackFrame.key) === requestKey ? stackFrame.token : void 0) || getMatchingRequestToken(token, requestKey);
468
+ const { [DIRTY_DISPATCH_TOKEN]: _dirtyDispatchToken, ...cleanOptions } = options;
469
+ const configWithToken = activeToken ? { ...cleanOptions, [DIRTY_DISPATCH_TOKEN]: activeToken } : cleanOptions;
470
+ if (activeToken) {
471
+ activeToken.requestKey ||= requestKey;
472
+ requestTokenStack.push({ key: requestKey, token: activeToken });
473
+ }
474
+ try {
475
+ return Reflect.apply(getCurrentRequest(), proxy, [configWithToken]);
476
+ } finally {
477
+ if (activeToken) {
478
+ requestTokenStack.pop();
479
+ }
480
+ }
481
+ }, "value")
482
+ },
483
+ resource: {
484
+ configurable: true,
485
+ value: /* @__PURE__ */ __name((...args) => {
486
+ const resourceKey = getResourceDispatchKey(args[0], args[1], args[2]);
487
+ const stackFrame = resourceTokenStack.at(-1);
488
+ const activeToken = getMatchingResourceToken(token, resourceKey) || (resourceKey && (stackFrame == null ? void 0 : stackFrame.key) === resourceKey ? stackFrame.token : void 0);
489
+ return dispatchResource(activeToken, args);
490
+ }, "value")
491
+ }
492
+ });
493
+ return receiver;
494
+ }, "createDispatchReceiver");
321
495
  const resource = /* @__PURE__ */ __name((name, of, headers, cancel) => {
322
- const targetResource = api.resource(name, of, headers, cancel);
323
- return createDirtyAwareResource(context, targetResource, name, of, headers);
496
+ const resourceKey = getResourceDispatchKey(name, of, headers);
497
+ const stackFrame = resourceTokenStack.at(-1);
498
+ const parentToken = resourceKey && (stackFrame == null ? void 0 : stackFrame.key) === resourceKey ? stackFrame.token : void 0;
499
+ const receiver = createDispatchReceiver(parentToken);
500
+ const resourceInstance = Reflect.apply(baseResource, shouldUseResourceDispatchReceiver ? receiver : api, [
501
+ name,
502
+ of,
503
+ headers,
504
+ cancel
505
+ ]);
506
+ return createDirtyAwareResource(context, resourceInstance, name, of, headers, requestTokenStack, parentToken);
324
507
  }, "resource");
325
508
  const request = /* @__PURE__ */ __name((config) => {
326
509
  const options = config;
510
+ const requestKey = getRequestDispatchKey(options, context);
511
+ const stackFrame = requestTokenStack.at(-1);
512
+ const inheritedToken = getMatchingRequestToken(options == null ? void 0 : options[DIRTY_DISPATCH_TOKEN], requestKey) || (requestKey && (stackFrame == null ? void 0 : stackFrame.key) === requestKey ? stackFrame.token : void 0);
513
+ const token = inheritedToken || { marked: false, skip: false };
514
+ const ownsToken = !inheritedToken;
515
+ token.requestKey ||= requestKey;
516
+ if (typeof options.resource === "string") {
517
+ token.resourceKey ||= getResourceDispatchKey(options.resource, options.resourceOf, options.headers);
518
+ }
327
519
  const skipDataSourceDirty = options == null ? void 0 : options[SKIP_DATA_SOURCE_DIRTY];
328
- const dirtyResourceAction = skipDataSourceDirty ? void 0 : resolveDirtyResourceAction(options, context);
329
- const { [SKIP_DATA_SOURCE_DIRTY]: _skipDataSourceDirty, ...cleanConfig } = options;
330
- return api.request(cleanConfig).then((result) => {
331
- if (dirtyResourceAction && isMutatingResourceAction(dirtyResourceAction.actionName)) {
332
- markResourceActionDataSourceDirty(context, dirtyResourceAction, options.headers);
520
+ if (skipDataSourceDirty) {
521
+ token.skip = true;
522
+ }
523
+ const dirtyResourceAction = resolveDirtyResourceAction(options, context);
524
+ const {
525
+ [DIRTY_DISPATCH_TOKEN]: _dirtyDispatchToken,
526
+ [SKIP_DATA_SOURCE_DIRTY]: _skipDataSourceDirty,
527
+ ...cleanConfig
528
+ } = options;
529
+ const receiver = createDispatchReceiver(token);
530
+ return Reflect.apply(baseRequest, shouldUseRequestDispatchReceiver ? receiver : api, [cleanConfig]).then((result) => {
531
+ if (ownsToken) {
532
+ markResourceActionDataSourceDirtyOnce(token, context, dirtyResourceAction, options.headers);
333
533
  }
334
534
  return result;
335
535
  });
336
536
  }, "request");
537
+ const isLockedOwnProperty = /* @__PURE__ */ __name((target, prop) => {
538
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
539
+ return !!descriptor && !descriptor.configurable;
540
+ }, "isLockedOwnProperty");
337
541
  const proxy = new Proxy(api, {
338
542
  get(target, prop, receiver) {
339
543
  if (prop === "resource") {
340
- return resource;
544
+ if (isLockedOwnProperty(target, prop)) {
545
+ return Reflect.get(target, prop, receiver);
546
+ }
547
+ return hasResourceOverride ? resourceOverride : resource;
341
548
  }
342
549
  if (prop === "request") {
343
- return request;
550
+ if (isLockedOwnProperty(target, prop)) {
551
+ return Reflect.get(target, prop, receiver);
552
+ }
553
+ return hasRequestOverride ? requestOverride : request;
344
554
  }
345
555
  return Reflect.get(target, prop, receiver);
556
+ },
557
+ set(target, prop, value, receiver) {
558
+ if (prop === "resource") {
559
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
560
+ if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
561
+ return false;
562
+ }
563
+ hasResourceOverride = true;
564
+ resourceOverride = value;
565
+ return true;
566
+ }
567
+ if (prop === "request") {
568
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
569
+ if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
570
+ return false;
571
+ }
572
+ hasRequestOverride = true;
573
+ requestOverride = value;
574
+ return true;
575
+ }
576
+ return Reflect.set(target, prop, value, receiver);
577
+ },
578
+ deleteProperty(target, prop) {
579
+ if (prop !== "resource" && prop !== "request") {
580
+ return Reflect.deleteProperty(target, prop);
581
+ }
582
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
583
+ if (descriptor && (!descriptor.configurable || !Reflect.isExtensible(target))) {
584
+ return false;
585
+ }
586
+ if (prop === "resource") {
587
+ hasResourceOverride = false;
588
+ resourceOverride = void 0;
589
+ } else {
590
+ hasRequestOverride = false;
591
+ requestOverride = void 0;
592
+ }
593
+ return true;
594
+ },
595
+ defineProperty(target, prop, descriptor) {
596
+ if (prop === "resource" || prop === "request") {
597
+ return false;
598
+ }
599
+ return Reflect.defineProperty(target, prop, descriptor);
346
600
  }
347
601
  });
348
602
  dirtyAwareApiClientProxies.add(proxy);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.10",
3
+ "version": "2.2.0-beta.11",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-beta.10",
12
- "@nocobase/shared": "2.2.0-beta.10",
11
+ "@nocobase/sdk": "2.2.0-beta.11",
12
+ "@nocobase/shared": "2.2.0-beta.11",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "d572beec24de46df948f28db72b25303a63bf62d"
40
+ "gitHead": "8dab25af2bb83a81da68158d0dcf836f96103d61"
41
41
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { APIClient, type IResource, type RequestOptions } from '@nocobase/sdk';
11
12
  import { FlowContext } from '../../flowContext';
12
13
  import { FlowEngine } from '../../flowEngine';
13
14
  import { createViewScopedEngine } from '../../ViewScopedFlowEngine';
@@ -80,6 +81,326 @@ describe('dirtyAwareApiClient', () => {
80
81
  expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
81
82
  });
82
83
 
84
+ it('should keep request overrides local to the dirty-aware proxy', async () => {
85
+ const engine = new FlowEngine();
86
+ const request = vi.fn(async () => ({ data: { ok: true } }));
87
+ const api: TestApi = {
88
+ auth: { locale: 'zh-CN' },
89
+ request,
90
+ resource: vi.fn(),
91
+ };
92
+ const originalRequest = api.request;
93
+ const wrappedApi = getWrappedApi(engine, api);
94
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
95
+ const requestOverride = vi.fn((config: TestRequestOptions) => delegatedRequest(config));
96
+
97
+ wrappedApi.request = requestOverride;
98
+
99
+ await wrappedApi.request({ url: 'posts:list' });
100
+
101
+ expect(requestOverride).toHaveBeenCalledTimes(1);
102
+ expect(request).toHaveBeenCalledTimes(1);
103
+ expect(api.request).toBe(originalRequest);
104
+ });
105
+
106
+ it('should keep resource overrides local to the dirty-aware proxy', async () => {
107
+ const engine = new FlowEngine();
108
+ const update = vi.fn(async () => ({ data: { ok: true } }));
109
+ const api: TestApi = {
110
+ auth: { locale: 'zh-CN' },
111
+ request: vi.fn(async () => ({ data: { ok: true } })),
112
+ resource: vi.fn(() => ({ update })),
113
+ };
114
+ const originalResource = api.resource;
115
+ const wrappedApi = getWrappedApi(engine, api);
116
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
117
+ const resourceOverride = vi.fn((...args: Parameters<TestApi['resource']>) => delegatedResource(...args));
118
+
119
+ wrappedApi.resource = resourceOverride;
120
+
121
+ await wrappedApi.resource('posts').update({ filterByTk: 1 });
122
+
123
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
124
+ expect(update).toHaveBeenCalledTimes(1);
125
+ expect(api.resource).toBe(originalResource);
126
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
127
+ });
128
+
129
+ it('should route resource actions through the local request override without double-marking', async () => {
130
+ const engine = new FlowEngine();
131
+ const api = new APIClient();
132
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
133
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
134
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
135
+ const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => delegatedRequest(config));
136
+
137
+ wrappedApi.request = requestOverride as APIClient['request'];
138
+
139
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
140
+
141
+ expect(requestOverride).toHaveBeenCalledTimes(1);
142
+ expect(transport).toHaveBeenCalledTimes(1);
143
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
144
+ });
145
+
146
+ it('should invoke custom request methods on the original api instance', async () => {
147
+ class StatefulRequestAPIClient extends APIClient {
148
+ #privateRequestCalls = 0;
149
+ publicRequestCalls = 0;
150
+
151
+ get privateRequestCalls() {
152
+ return this.#privateRequestCalls;
153
+ }
154
+
155
+ override request<T, R, D>(config: Parameters<APIClient['request']>[0]): Promise<R> {
156
+ this.#privateRequestCalls += 1;
157
+ this.publicRequestCalls += 1;
158
+ return super.request<T, R, D>(config);
159
+ }
160
+ }
161
+
162
+ const engine = new FlowEngine();
163
+ const api = new StatefulRequestAPIClient();
164
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
165
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
166
+
167
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
168
+
169
+ expect(transport).toHaveBeenCalledTimes(1);
170
+ expect(api.privateRequestCalls).toBe(1);
171
+ expect(api.publicRequestCalls).toBe(1);
172
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
173
+ });
174
+
175
+ it('should invoke custom resource methods on the original api instance', async () => {
176
+ class StatefulResourceAPIClient extends APIClient {
177
+ #privateResourceCalls = 0;
178
+ publicResourceCalls = 0;
179
+
180
+ get privateResourceCalls() {
181
+ return this.#privateResourceCalls;
182
+ }
183
+
184
+ override resource(...args: Parameters<APIClient['resource']>): IResource {
185
+ this.#privateResourceCalls += 1;
186
+ this.publicResourceCalls += 1;
187
+ return super.resource(...args);
188
+ }
189
+ }
190
+
191
+ const engine = new FlowEngine();
192
+ const api = new StatefulResourceAPIClient();
193
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
194
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
195
+
196
+ await wrappedApi.request({
197
+ resource: 'posts',
198
+ action: 'update',
199
+ params: { filterByTk: 1, values: { title: 'updated' } },
200
+ });
201
+
202
+ expect(transport).toHaveBeenCalledTimes(1);
203
+ expect(api.privateResourceCalls).toBe(1);
204
+ expect(api.publicResourceCalls).toBe(1);
205
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
206
+ });
207
+
208
+ it('should keep one dirty mark when the request override rebuilds the config', async () => {
209
+ const engine = new FlowEngine();
210
+ const api = new APIClient();
211
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
212
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
213
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
214
+ const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => {
215
+ const { url, method, headers, params, data } = config as RequestOptions;
216
+ return delegatedRequest({ url, method, headers, params, data });
217
+ });
218
+
219
+ wrappedApi.request = requestOverride as APIClient['request'];
220
+
221
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
222
+
223
+ expect(requestOverride).toHaveBeenCalledTimes(1);
224
+ expect(transport).toHaveBeenCalledTimes(1);
225
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
226
+ });
227
+
228
+ it('should keep nested mutations from a request override independent', async () => {
229
+ const engine = new FlowEngine();
230
+ const api = new APIClient();
231
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
232
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
233
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
234
+ const requestOverride = vi.fn(async (config: Parameters<APIClient['request']>[0]) => {
235
+ await delegatedRequest({ url: 'comments:create', method: 'post' });
236
+ return delegatedRequest(config);
237
+ });
238
+
239
+ wrappedApi.request = requestOverride as APIClient['request'];
240
+
241
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
242
+
243
+ expect(requestOverride).toHaveBeenCalledTimes(1);
244
+ expect(transport).toHaveBeenCalledTimes(2);
245
+ expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
246
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
247
+ });
248
+
249
+ it('should route structured requests through the local resource override without double-marking', async () => {
250
+ const engine = new FlowEngine();
251
+ const api = new APIClient();
252
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
253
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
254
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
255
+ const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => delegatedResource(...args));
256
+
257
+ wrappedApi.resource = resourceOverride;
258
+
259
+ await wrappedApi.request({
260
+ resource: 'posts',
261
+ action: 'update',
262
+ params: { filterByTk: 1, values: { title: 'updated' } },
263
+ });
264
+
265
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
266
+ expect(transport).toHaveBeenCalledTimes(1);
267
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
268
+ });
269
+
270
+ it('should keep one dirty mark when the resource override delegates lazily', async () => {
271
+ const engine = new FlowEngine();
272
+ const api = new APIClient();
273
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
274
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
275
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
276
+ const resourceOverride = vi.fn(
277
+ (...args: Parameters<APIClient['resource']>): IResource => ({
278
+ update: async (...actionArgs: Parameters<IResource['update']>) => {
279
+ await Promise.resolve();
280
+ return delegatedResource(...args).update(...actionArgs);
281
+ },
282
+ }),
283
+ );
284
+
285
+ wrappedApi.resource = resourceOverride;
286
+
287
+ await wrappedApi.request({
288
+ resource: 'posts',
289
+ action: 'update',
290
+ params: { filterByTk: 1, values: { title: 'updated' } },
291
+ });
292
+
293
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
294
+ expect(transport).toHaveBeenCalledTimes(1);
295
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
296
+ });
297
+
298
+ it('should keep nested mutations from a resource override independent', async () => {
299
+ const engine = new FlowEngine();
300
+ const api = new APIClient();
301
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
302
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
303
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
304
+ let nestedMutation: Promise<unknown> | undefined;
305
+ const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => {
306
+ nestedMutation = delegatedResource('comments').create({ values: { body: 'nested' } });
307
+ return delegatedResource(...args);
308
+ });
309
+
310
+ wrappedApi.resource = resourceOverride;
311
+
312
+ await wrappedApi.request({
313
+ resource: 'posts',
314
+ action: 'update',
315
+ params: { filterByTk: 1, values: { title: 'updated' } },
316
+ });
317
+ await nestedMutation;
318
+
319
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
320
+ expect(transport).toHaveBeenCalledTimes(2);
321
+ expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
322
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
323
+ });
324
+
325
+ it('should clear local request and resource overrides when deleted', () => {
326
+ const engine = new FlowEngine();
327
+ const api: TestApi = {
328
+ auth: { locale: 'zh-CN' },
329
+ request: vi.fn(async () => ({ data: { ok: true } })),
330
+ resource: vi.fn(() => ({})),
331
+ };
332
+ const wrappedApi = getWrappedApi(engine, api);
333
+ const defaultRequest = wrappedApi.request;
334
+ const defaultResource = wrappedApi.resource;
335
+
336
+ wrappedApi.request = vi.fn();
337
+ wrappedApi.resource = vi.fn();
338
+
339
+ expect(Reflect.deleteProperty(wrappedApi, 'request')).toBe(true);
340
+ expect(Reflect.deleteProperty(wrappedApi, 'resource')).toBe(true);
341
+ expect(wrappedApi.request).toBe(defaultRequest);
342
+ expect(wrappedApi.resource).toBe(defaultResource);
343
+ });
344
+
345
+ it('should reject request and resource descriptor overrides without mutating the raw api', () => {
346
+ const engine = new FlowEngine();
347
+ const api: TestApi = {
348
+ auth: { locale: 'zh-CN' },
349
+ request: vi.fn(async () => ({ data: { ok: true } })),
350
+ resource: vi.fn(() => ({})),
351
+ };
352
+ const wrappedApi = getWrappedApi(engine, api);
353
+ const originalRequest = api.request;
354
+ const originalResource = api.resource;
355
+
356
+ expect(Reflect.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toBe(false);
357
+ expect(Reflect.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toBe(false);
358
+ expect(() => Object.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toThrow(TypeError);
359
+ expect(() => Object.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toThrow(TypeError);
360
+ expect(api.request).toBe(originalRequest);
361
+ expect(api.resource).toBe(originalResource);
362
+ });
363
+
364
+ it('should reject local overrides for own methods on a non-extensible raw api', () => {
365
+ const engine = new FlowEngine();
366
+ const api: TestApi = {
367
+ auth: { locale: 'zh-CN' },
368
+ request: vi.fn(async () => ({ data: { ok: true } })),
369
+ resource: vi.fn(() => ({})),
370
+ };
371
+ const wrappedApi = getWrappedApi(engine, api);
372
+ const defaultMethods = {
373
+ request: wrappedApi.request,
374
+ resource: wrappedApi.resource,
375
+ };
376
+
377
+ Object.preventExtensions(api);
378
+
379
+ for (const methodName of ['request', 'resource'] as const) {
380
+ expect(Reflect.set(wrappedApi, methodName, vi.fn())).toBe(false);
381
+ expect(wrappedApi[methodName]).toBe(defaultMethods[methodName]);
382
+ expect(Reflect.deleteProperty(wrappedApi, methodName)).toBe(false);
383
+ }
384
+ });
385
+
386
+ it('should keep overrides isolated between flow contexts', () => {
387
+ const api: TestApi = {
388
+ auth: { locale: 'zh-CN' },
389
+ request: vi.fn(async () => ({ data: { ok: true } })),
390
+ resource: vi.fn(() => ({})),
391
+ };
392
+ const firstContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
393
+ const secondContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
394
+ const secondRequest = secondContextApi.request;
395
+ const requestOverride = vi.fn();
396
+
397
+ firstContextApi.request = requestOverride;
398
+
399
+ expect(firstContextApi.request).toBe(requestOverride);
400
+ expect(secondContextApi.request).toBe(secondRequest);
401
+ expect(api.request).not.toBe(requestOverride);
402
+ });
403
+
83
404
  it('should not mark dirty for read actions', async () => {
84
405
  const nonMutatingActions = [
85
406
  'get',
@@ -7,13 +7,28 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import type { ActionParams, APIClient, IResource, RequestOptions } from '@nocobase/sdk';
10
+ import { APIClient } from '@nocobase/sdk';
11
+ import type { ActionParams, IResource, RequestOptions } from '@nocobase/sdk';
11
12
  import type { FlowContext } from '../flowContext';
12
13
  import { getDataSourceKeyFromHeaders, markDataSourceDirty } from './dataSourceDirty';
13
14
 
14
15
  type ResourceActionFn = (params?: ActionParams, opts?: unknown) => Promise<unknown>;
15
16
 
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
+ };
17
32
 
18
33
  type ResourceRequestOptions = RequestOptions & {
19
34
  resource?: unknown;
@@ -21,6 +36,7 @@ type ResourceRequestOptions = RequestOptions & {
21
36
  action?: unknown;
22
37
  headers?: unknown;
23
38
  [SKIP_DATA_SOURCE_DIRTY]?: boolean;
39
+ [DIRTY_DISPATCH_TOKEN]?: DirtyDispatchToken;
24
40
  };
25
41
 
26
42
  type DirtyResourceAction = {
@@ -329,6 +345,53 @@ function resolveDirtyResourceAction(
329
345
  return parseDirtyResourceActionFromUrl(options?.url, context);
330
346
  }
331
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
+
332
395
  function markResourceActionDataSourceDirty(
333
396
  context: FlowContext,
334
397
  dirtyResourceAction: DirtyResourceAction,
@@ -342,12 +405,31 @@ function markResourceActionDataSourceDirty(
342
405
  });
343
406
  }
344
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
+
345
425
  function createDirtyAwareResource(
346
426
  context: FlowContext,
347
427
  resource: IResource,
348
428
  resourceName: string,
349
429
  resourceOf: unknown,
350
430
  headers: unknown,
431
+ requestTokenStack: DirtyDispatchFrame[],
432
+ parentToken?: DirtyDispatchToken,
351
433
  ): IResource {
352
434
  return new Proxy(resource, {
353
435
  get(target, prop, receiver) {
@@ -358,10 +440,42 @@ function createDirtyAwareResource(
358
440
 
359
441
  const action = original as ResourceActionFn;
360
442
  return async (...args: Parameters<ResourceActionFn>) => {
361
- const result = await action(...args);
443
+ const actionOptions = isObjectRecord(args[1]) ? args[1] : undefined;
362
444
  const dirtyResourceAction = resolveDirtyResourceActionFromResource(resourceName, resourceOf, prop, context);
363
- if (dirtyResourceAction) {
364
- markResourceActionDataSourceDirty(context, dirtyResourceAction, headers);
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);
365
479
  }
366
480
  return result;
367
481
  };
@@ -370,34 +484,232 @@ function createDirtyAwareResource(
370
484
  }
371
485
 
372
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
+
373
600
  const resource: APIClient['resource'] = (name, of, headers, cancel) => {
374
- const targetResource = api.resource(name, of, headers, cancel);
375
- return createDirtyAwareResource(context, targetResource, name, of, headers);
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);
376
612
  };
377
613
 
378
614
  const request = (<T, R, D>(config: APIClientRequestConfig): Promise<R> => {
379
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
+ }
380
627
  const skipDataSourceDirty = options?.[SKIP_DATA_SOURCE_DIRTY];
381
- const dirtyResourceAction = skipDataSourceDirty ? undefined : resolveDirtyResourceAction(options, context);
382
- const { [SKIP_DATA_SOURCE_DIRTY]: _skipDataSourceDirty, ...cleanConfig } = options;
383
- return api.request<T, R, D>(cleanConfig as typeof config).then((result) => {
384
- if (dirtyResourceAction && isMutatingResourceAction(dirtyResourceAction.actionName)) {
385
- markResourceActionDataSourceDirty(context, dirtyResourceAction, options.headers);
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);
386
643
  }
387
644
  return result;
388
645
  });
389
646
  }) as APIClient['request'];
390
647
 
648
+ const isLockedOwnProperty = (target: DirtyAwareAPIClient, prop: PropertyKey) => {
649
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
650
+ return !!descriptor && !descriptor.configurable;
651
+ };
652
+
391
653
  const proxy = new Proxy(api, {
392
654
  get(target, prop, receiver) {
393
655
  if (prop === 'resource') {
394
- return resource;
656
+ if (isLockedOwnProperty(target, prop)) {
657
+ return Reflect.get(target, prop, receiver);
658
+ }
659
+ return hasResourceOverride ? resourceOverride : resource;
395
660
  }
396
661
  if (prop === 'request') {
397
- return request;
662
+ if (isLockedOwnProperty(target, prop)) {
663
+ return Reflect.get(target, prop, receiver);
664
+ }
665
+ return hasRequestOverride ? requestOverride : request;
398
666
  }
399
667
  return Reflect.get(target, prop, receiver);
400
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
+ },
401
713
  }) as APIClient;
402
714
  dirtyAwareApiClientProxies.add(proxy);
403
715
  return proxy;