refactor!: simplify query and session ownership
Use Query cancellation and native mutation results instead of HTTP registries, access epochs and copied scope state. Keep local auth ordering and mutation identity fences. Allow staged generated files to remain excluded from ESLint. Backend contract: WealthLedger/backend@d698ed9 BREAKING CHANGE: drop investment stateVersion/progress/resources from the client contract and use data/metadata readiness instead of rewritten query status. Cross-tab auth may require re-login.
This commit is contained in:
@@ -23,14 +23,6 @@ type AuthHandlers = {
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
|
||||
const latestReads = new Map<string, { pending: number; sequence: number }>();
|
||||
|
||||
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
|
||||
for (const request of inFlight)
|
||||
if (isMatches(request.pathname)) request.invalidated = true;
|
||||
}
|
||||
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
hasSession: false,
|
||||
};
|
||||
@@ -38,48 +30,6 @@ const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("sync");
|
||||
url.searchParams.sort();
|
||||
const key = `${request.method} ${url}`;
|
||||
const ticket = { invalidated: false, pathname: url.pathname };
|
||||
|
||||
const read
|
||||
= request.method === "GET"
|
||||
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
|
||||
: undefined;
|
||||
|
||||
if (read) {
|
||||
read.sequence += 1;
|
||||
read.pending += 1;
|
||||
latestReads.set(key, read);
|
||||
}
|
||||
|
||||
const sequence = read?.sequence;
|
||||
inFlight.add(ticket);
|
||||
|
||||
try {
|
||||
return await fetchCurrent(
|
||||
request,
|
||||
() => !ticket.invalidated && (!read || sequence === read.sequence),
|
||||
);
|
||||
}
|
||||
finally {
|
||||
inFlight.delete(ticket);
|
||||
if (read && --read.pending === 0) latestReads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers?: AuthHandlers) {
|
||||
if (!handlers) invalidateApiRequests(() => true);
|
||||
sessionState.handlers = handlers;
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
async function fetchCurrent(
|
||||
request: Request,
|
||||
isLatest: () => boolean,
|
||||
): Promise<Response> {
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
);
|
||||
@@ -91,7 +41,7 @@ async function fetchCurrent(
|
||||
(session?.generation
|
||||
? generation === session.generation()
|
||||
&& generation === sessionState.handlers?.generation?.()
|
||||
: session === sessionState.handlers) && isLatest();
|
||||
: session === sessionState.handlers);
|
||||
|
||||
const token = session?.accessToken();
|
||||
|
||||
@@ -107,7 +57,6 @@ async function fetchCurrent(
|
||||
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
await response.clone().arrayBuffer();
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (
|
||||
@@ -139,12 +88,16 @@ async function fetchCurrent(
|
||||
const refreshedToken = sessionState.handlers.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
await retried.clone().arrayBuffer();
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
return retried;
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers?: AuthHandlers) {
|
||||
sessionState.handlers = handlers;
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
function hasAuthenticatedSession() {
|
||||
return sessionState.hasSession;
|
||||
}
|
||||
@@ -174,6 +127,5 @@ export {
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
|
||||
invalidateApiRequests,
|
||||
subscribeAuthenticatedSession,
|
||||
};
|
||||
|
||||
Vendored
+1
-12
@@ -915,12 +915,11 @@ export interface components {
|
||||
InvestmentSyncDto: {
|
||||
id: string | null;
|
||||
/** @enum {string} */
|
||||
state: "idle" | "queued" | "running" | "failed";
|
||||
state: "idle" | "running" | "failed";
|
||||
errorCode: string | null;
|
||||
};
|
||||
InvestmentDependencyDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -934,7 +933,6 @@ export interface components {
|
||||
};
|
||||
InvestmentResourceMetaDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1126,7 +1124,6 @@ export interface components {
|
||||
};
|
||||
IncomeDependencyDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1142,7 +1139,6 @@ export interface components {
|
||||
};
|
||||
IncomeAnalyticsMetaDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1622,11 +1618,6 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
now: string;
|
||||
};
|
||||
TbankSyncProgressDto: {
|
||||
completed: number;
|
||||
total: number;
|
||||
discovering: boolean;
|
||||
};
|
||||
TbankSyncStatusDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1636,8 +1627,6 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
state: "running" | "succeeded" | "failed" | "cancelled";
|
||||
errorCode: string | null;
|
||||
progress: components["schemas"]["TbankSyncProgressDto"];
|
||||
resources: components["schemas"]["InvestmentResourceMetaDto"][];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
|
||||
@@ -6,7 +6,6 @@ export {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
invalidateApiRequests,
|
||||
} from "./client";
|
||||
export type { paths } from "./generated/schema";
|
||||
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
@@ -37,7 +37,7 @@ function auth() {
|
||||
}
|
||||
|
||||
describe("persistent resource query lifecycle", () => {
|
||||
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
|
||||
it("observes once into the canonical cache without another import", async () => {
|
||||
auth();
|
||||
const modes: (null | string)[] = [];
|
||||
let observations = 0;
|
||||
@@ -51,7 +51,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(snapshot, {
|
||||
stateVersion: observations > 1 ? "new" : "old",
|
||||
dataRevision: observations > 0 ? "new" : "old",
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -60,16 +60,16 @@ describe("persistent resource query lifecycle", () => {
|
||||
const { queryClient, wrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await act(() => result.current.observe("new"));
|
||||
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||
await act(() => result.current.observe());
|
||||
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
|
||||
|
||||
expect(modes).toEqual([null, "observe", "observe"]);
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(
|
||||
queryClient.getQueryData(resourceOptions("a").queryKey),
|
||||
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
|
||||
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
|
||||
});
|
||||
it("does not convert missing or failed source data to empty financial success", async () => {
|
||||
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
|
||||
auth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
@@ -91,24 +91,17 @@ describe("persistent resource query lifecycle", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.status).toBe("success");
|
||||
expect(result.current.isPending).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
expect(result.current.transport).toEqual(
|
||||
{
|
||||
error: null,
|
||||
fetchStatus: "idle",
|
||||
status: "success",
|
||||
},
|
||||
);
|
||||
expect(result.current.meta?.availability).toBe("missing");
|
||||
});
|
||||
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
|
||||
auth();
|
||||
let isReady = false;
|
||||
const refreshed = resource(snapshot, { stateVersion: "ready" });
|
||||
const refreshed = resource(snapshot, { dataRevision: "ready" });
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(isReady ? refreshed : resource(null)),
|
||||
@@ -120,7 +113,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.transport.status).toBe("success"),
|
||||
expect(result.current.status).toBe("success"),
|
||||
);
|
||||
await act(async () => {
|
||||
const missing = await result.current.refetch();
|
||||
@@ -140,35 +133,40 @@ describe("persistent resource query lifecycle", () => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||
it("keeps the observed snapshot when an older query finishes later", async () => {
|
||||
auth();
|
||||
|
||||
const { promise: blocked, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
let isStarted = false;
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ request }) => {
|
||||
if (!new URL(request.url).searchParams.has("sync")) await blocked;
|
||||
if (!new URL(request.url).searchParams.has("sync")) {
|
||||
isStarted = true;
|
||||
await blocked;
|
||||
|
||||
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
|
||||
}
|
||||
|
||||
return HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const old = apiClient.GET(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "a" } } },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||
const { result } = renderHook(() => usePrivateResource("a"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
release();
|
||||
await rejected;
|
||||
|
||||
await waitFor(() => expect(isStarted).toBe(true));
|
||||
await act(() => result.current.observe());
|
||||
await act(async () => {
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
expect(result.current.data).toEqual(snapshot);
|
||||
});
|
||||
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||
auth();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type QueryFunctionContext,
|
||||
useQueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -13,8 +12,6 @@ import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||
|
||||
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
||||
|
||||
type Resource = {
|
||||
@@ -50,9 +47,6 @@ function invalidateResourceAccess(
|
||||
].every(code => !isApiErrorCode(errorCode, code))
|
||||
)
|
||||
return;
|
||||
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||
accessEpochs.set(client, epochs);
|
||||
epochs.set(group, (epochs.get(group) ?? 0) + 1);
|
||||
|
||||
const affected = {
|
||||
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||
@@ -81,33 +75,6 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
|
||||
}] as unknown as K;
|
||||
}
|
||||
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
const isMissing = result.isSuccess && result.data.data === null;
|
||||
const data: T["data"] | undefined = result.data?.data;
|
||||
const meta: T["meta"] | undefined = result.data?.meta;
|
||||
|
||||
const status: UseQueryResult<T, E>["status"] = isMissing
|
||||
? "pending"
|
||||
: result.status;
|
||||
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...result,
|
||||
data: data ?? undefined,
|
||||
isPending: result.isPending || isMissing,
|
||||
isSuccess: result.isSuccess && !isMissing,
|
||||
meta,
|
||||
status,
|
||||
transport: {
|
||||
error: result.error,
|
||||
fetchStatus: result.fetchStatus,
|
||||
status: result.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Observation changes transport mode, never the generated cache identity.
|
||||
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
@@ -119,35 +86,13 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
? options.meta.accessGroup
|
||||
: undefined;
|
||||
|
||||
let epochs = accessEpochs.get(client);
|
||||
|
||||
if (!epochs) {
|
||||
epochs = new Map();
|
||||
accessEpochs.set(client, epochs);
|
||||
}
|
||||
|
||||
const groupEpochs = epochs;
|
||||
const queryFunction = options.queryFn;
|
||||
|
||||
const guarded
|
||||
= typeof queryFunction === "function"
|
||||
? async (context: QueryFunctionContext<K>) => {
|
||||
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||
const result = await queryFunction(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
|
||||
return result;
|
||||
}
|
||||
: queryFunction;
|
||||
|
||||
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||
const query = useAuthenticatedQuery(options);
|
||||
useEffect(() => {
|
||||
if (group) invalidateResourceAccess(client, group, query.error);
|
||||
}, [client, group, query.error]);
|
||||
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFunction = guarded;
|
||||
const observe = async () => {
|
||||
const queryFunction = options.queryFn;
|
||||
if (
|
||||
typeof queryFunction !== "function"
|
||||
|| !query.isEnabled
|
||||
@@ -162,33 +107,34 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const queryKey = observationKey(context.queryKey);
|
||||
|
||||
const response = await queryFunction({ ...context, queryKey });
|
||||
|
||||
if (
|
||||
expectedStateVersion
|
||||
&& response.meta.stateVersion !== expectedStateVersion
|
||||
) {
|
||||
// One more observational read; never turn a stale notification into an import.
|
||||
const current = await queryFunction({ ...context, queryKey });
|
||||
if (current.meta.stateVersion !== expectedStateVersion)
|
||||
throw new DOMException("Resource changed", "AbortError");
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
return response;
|
||||
return queryFunction({ ...context, queryKey });
|
||||
},
|
||||
retry: false,
|
||||
// Observation validates raw resource metadata, before any observer projection.
|
||||
// Keep the cache envelope independent of observer projection.
|
||||
select: undefined,
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const data: T["data"] | undefined = query.data?.data;
|
||||
const meta: T["meta"] | undefined = query.data?.meta;
|
||||
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...unwrapResourceResult(query),
|
||||
data: data ?? undefined,
|
||||
error: query.error,
|
||||
fetchStatus: query.fetchStatus,
|
||||
isEnabled: query.isEnabled,
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
isLoading: query.isLoading,
|
||||
isPending: query.isPending,
|
||||
isSuccess: query.isSuccess,
|
||||
meta,
|
||||
observe,
|
||||
queryKey: options.queryKey,
|
||||
refetch: query.refetch,
|
||||
status: query.status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5947,7 +5947,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"idle",
|
||||
"queued",
|
||||
"running",
|
||||
"failed"
|
||||
]
|
||||
@@ -5969,9 +5968,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6008,7 +6004,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6022,9 +6017,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6067,7 +6059,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6912,9 +6903,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6959,7 +6947,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6974,9 +6961,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7020,7 +7004,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -9017,25 +9000,6 @@
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"TbankSyncProgressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed": {
|
||||
"type": "number"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"discovering": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"completed",
|
||||
"total",
|
||||
"discovering"
|
||||
]
|
||||
},
|
||||
"TbankSyncStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9087,15 +9051,6 @@
|
||||
"errorCode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"progress": {
|
||||
"$ref": "#/components/schemas/TbankSyncProgressDto"
|
||||
},
|
||||
"resources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9103,9 +9058,7 @@
|
||||
"scope",
|
||||
"now",
|
||||
"state",
|
||||
"errorCode",
|
||||
"progress",
|
||||
"resources"
|
||||
"errorCode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user